[react-search-refiners] Bug fixes + added previews + best bets (#649)

* * Updated data source connection for searchbox WP

* * Updated filters behavior. Now total count per refiner value is updated every time a new filter is selected like the default SharePoint behavior
* Updated shimmers
* Updated data sources binding logic to be more efficient

* * Updated static CSS classes to use SPFx modules instead
* Added previews for documents in the default list template
* Fixed bug https://github.com/SharePoint/sp-dev-fx-webparts/issues/642. The current page state was never updated after a new search.
* Fixed bug https://github.com/SharePoint/sp-dev-fx-webparts/issues/641. Now the result count is updated every time for each refiner.

* * CSS fixes

* * Removed unecessary lodash references
* Added responsive behavior for iframes using https://www.npmjs.com/package/on-el-resize

* * Updated README
* Added hover effect on the result image

* * Bug fix on videos

* * Added best bets support

* * Removed the jQuery reference. Replaced DOM manipulations by plain JS
This commit is contained in:
Franck Cornu 2018-10-24 02:26:16 -04:00 committed by Mikael Svenson
parent 1a7b1c1386
commit 910d377a77
42 changed files with 4926 additions and 4141 deletions

View File

@ -34,6 +34,7 @@ Version|Date|Comments
1.4 | May 10, 2018 | <ul><li>Added the query suggestions feature to the search box Web Part</li><li>Added the automatic translation for taxonomy filter values according to the current site locale.</li> <li>Added the option in the search box Web Part to send the query to an other page</ul> 1.4 | May 10, 2018 | <ul><li>Added the query suggestions feature to the search box Web Part</li><li>Added the automatic translation for taxonomy filter values according to the current site locale.</li> <li>Added the option in the search box Web Part to send the query to an other page</ul>
1.5 | Jul 2, 2018 | <ul><li>Added a templating feature for search results with Handlebars inspired by the [react-content-query-webpart](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-content-query-webpart) sample.</li><li>Upgraded to 1.5.1-plusbeta to use the new SPFx dynamic data feature instead of event aggregator for Web Parts communication.</li> <li>Code refactoring and reorganization.</ul> 1.5 | Jul 2, 2018 | <ul><li>Added a templating feature for search results with Handlebars inspired by the [react-content-query-webpart](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-content-query-webpart) sample.</li><li>Upgraded to 1.5.1-plusbeta to use the new SPFx dynamic data feature instead of event aggregator for Web Parts communication.</li> <li>Code refactoring and reorganization.</ul>
2.0.0.5 | Sept 18, 2018 | <ul><li>Upgraded to 1.6.0-plusbeta.</li><li>Added dynamic loading of parts needed in edit mode to reduce web part footprint.</li><li>Added configuration to sort.</li><li>Added option to set web part title.</li><li>Added result count tokens.</li><li>Added toggel to load/use handlebars helpers/moment.</li></ul> 2.0.0.5 | Sept 18, 2018 | <ul><li>Upgraded to 1.6.0-plusbeta.</li><li>Added dynamic loading of parts needed in edit mode to reduce web part footprint.</li><li>Added configuration to sort.</li><li>Added option to set web part title.</li><li>Added result count tokens.</li><li>Added toggel to load/use handlebars helpers/moment.</li></ul>
2.1.0.0 | 14 Oct, 2018 | <ul><li>Bug fixes ([#641](https://github.com/SharePoint/sp-dev-fx-webparts/issues/641),[#642](https://github.com/SharePoint/sp-dev-fx-webparts/issues/642))</li><li>Added document and Office 365 videos previews for the list template.</li><li>Added SharePoint best bets support.</li></ul>
## Disclaimer ## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.** **THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
@ -113,6 +114,35 @@ This Web Part allows you change customize the way you display your search result
<img src="./images/edit_template.png"/> <img src="./images/edit_template.png"/>
</p> </p>
#### Best bets
This WP supports SharePoint best bets via SharePoint query rules:
<p align="center">
<img src="./images/query_rules.png"/>
</p>
<p align="center">
<img src="./images/best_bets.png"/>
</p>
#### Elements previews
Previews are available, **only for the list view**, for Office documents and Office 365 videos (not Microsoft Stream). The embed URL is directly taken from the `ServerRedirectedEmbedURL` managed property retrieved from the search results.
<p align="center">
<img src="./images/result_preview.png"/>
</p>
The WebPart must have the following selected properties in the configuration to get the preview feature work (they are set by default):
- ServerRedirectedPreviewURL
- ServerRedirectedURL
- contentclass
- ServerRedirectedEmbedURL
- DefaultEncodingURL
This preview is displayed as an _iframe_ when the user clicks on the corresponding preview image. DOM manipulations occur to add the _iframe_ container dynamically aside with the _<img/>_ container.
#### Available tokens #### #### Available tokens ####
Setting | Description Setting | Description
@ -144,5 +174,6 @@ This Web Part illustrates the following concepts on top of the SharePoint Framew
- Integrate the [@pnp/spfx-property-controls](https://github.com/SharePoint/sp-dev-fx-property-controls) in your solution (*PlaceHolder* control). - Integrate the [@pnp/spfx-property-controls](https://github.com/SharePoint/sp-dev-fx-property-controls) in your solution (*PlaceHolder* control).
- Integrate multiple Office UI Fabric components (DocumentCard, Panel, GroupedList, ...) to fit with the native Office 365 theme. - Integrate multiple Office UI Fabric components (DocumentCard, Panel, GroupedList, ...) to fit with the native Office 365 theme.
- Use the React container component approach inspiring by the [react-todo-basic sample](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-todo-basic). - Use the React container component approach inspiring by the [react-todo-basic sample](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-todo-basic).
- Use [on-el-resize](https://www.npmjs.com/package/on-el-resize) by [Andrew Koltyakov](https://github.com/koltyakov) to resize iframes dynamically
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-search-refiners" /> <img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-search-refiners" />

View File

@ -27,7 +27,6 @@
] ]
} }
}, },
"externals": {},
"localizedResources": { "localizedResources": {
"SearchWebPartStrings": "lib/webparts/searchResults/loc/{locale}.js", "SearchWebPartStrings": "lib/webparts/searchResults/loc/{locale}.js",
"PropertyControlStrings": "./node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js", "PropertyControlStrings": "./node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js",

View File

@ -3,7 +3,7 @@
"solution": { "solution": {
"name": "PnP - Search Web Parts", "name": "PnP - Search Web Parts",
"id": "890affef-33e0-4d72-bd72-36399e02143b", "id": "890affef-33e0-4d72-bd72-36399e02143b",
"version": "2.0.0.5", "version": "2.1.0.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": false, "skipFeatureDeployment": false,
"features": [ "features": [

View File

@ -12,7 +12,7 @@
"pageUrl": "https://localhost:5432/workbench" "pageUrl": "https://localhost:5432/workbench"
}, },
"queryStringDataSource": { "queryStringDataSource": {
"pageUrl": "https://collaborationcorner.sharepoint.com/teams/PnPIntranet/SitePages/Search(1).aspx", "pageUrl": "https://collaborationcorner.sharepoint.com/teams/PnPIntranet/_layouts/15/workbench.aspx",
"customActions": { "customActions": {
"24cae67d-dec7-4eff-bb41-49451d5b5a11": { "24cae67d-dec7-4eff-bb41-49451d5b5a11": {
"location": "ClientSideExtension.ApplicationCustomizer", "location": "ClientSideExtension.ApplicationCustomizer",

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "react-search-refiners", "name": "react-search-refiners",
"version": "2.0.0", "version": "2.1.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -33,17 +33,18 @@
"@types/sharepoint": "2013.1.9", "@types/sharepoint": "2013.1.9",
"@types/webpack-env": "1.13.1", "@types/webpack-env": "1.13.1",
"common-tags": "^1.8.0", "common-tags": "^1.8.0",
"downshift": "^1.31.14", "downshift": "1.31.14",
"handlebars": "^4.0.12", "handlebars": "^4.0.12",
"handlebars-helpers": "^0.8.4", "handlebars-helpers": "^0.8.4",
"immutability-helper": "2.4.0", "immutability-helper": "2.4.0",
"lodash-es": "4.17.4", "office-ui-fabric-react": "5.124.0",
"office-ui-fabric-react": "^5.124.0", "on-el-resize": "0.0.4",
"react": "15.6.2", "react": "15.6.2",
"react-ace": "^6.1.4", "react-ace": "6.1.4",
"react-custom-scrollbars": "4.1.2", "react-custom-scrollbars": "4.1.2",
"react-dom": "15.6.2", "react-dom": "15.6.2",
"react-js-pagination": "3.0.0" "react-js-pagination": "3.0.0",
"video.js": "^7.1.0"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/sp-build-web": "1.6.0-plusbeta", "@microsoft/sp-build-web": "1.6.0-plusbeta",

View File

@ -43,6 +43,10 @@ export default class QueryStringDataSourceApplicationCustomizer
this.context.dynamicDataSourceManager.initializeSource(this); this.context.dynamicDataSourceManager.initializeSource(this);
if (this._searchQuery) {
this.context.dynamicDataSourceManager.notifySourceChanged();
}
return Promise.resolve(); return Promise.resolve();
} }
@ -70,24 +74,34 @@ export default class QueryStringDataSourceApplicationCustomizer
*/ */
private _bindPushState() { private _bindPushState() {
const _pushState = () => { const _pushState = (() => {
const _defaultPushState = history.pushState; const _defaultPushState = history.pushState;
const _self = this; const _self = this;
return function (data: any, title: string, url?: string | null) { return function (data: any, title: string, url?: string | null) {
const queryStringKeywords = UrlHelper.getQueryStringParam("q", url); _self._updateQuery(_self, url);
if (queryStringKeywords && queryStringKeywords !== _self._searchQuery) {
_self._searchQuery = queryStringKeywords;
_self.context.dynamicDataSourceManager.notifyPropertyChanged('queryStringQuery');
}
// Call the original function with the provided arguments // Call the original function with the provided arguments
// This context is necessary for the context of the history change // This context is necessary for the context of the history change
return _defaultPushState.apply(this, [data, title, url]); return _defaultPushState.apply(this, [data, title, url]);
}; };
}; }).bind(this);
history.pushState = _pushState(); history.pushState = _pushState();
// Used when press the "back" button
window.onpopstate = ((ev: PopStateEvent) => {
this._updateQuery(this, ev.state.url);
}).bind(this);
}
private _updateQuery(currentObject: QueryStringDataSourceApplicationCustomizer, url: string) {
const queryStringKeywords = UrlHelper.getQueryStringParam("q", url);
if (queryStringKeywords) {
currentObject._searchQuery = queryStringKeywords;
currentObject.context.dynamicDataSourceManager.notifyPropertyChanged("queryStringQuery");
}
} }
} }

View File

@ -0,0 +1,27 @@
/**
* Helper methods for plain JS DOM manipulations
* https://plainjs.com/javascript/
*/
export class DomHelper {
/**
* Iterates over a list of DOM nodes (https://toddmotto.com/ditch-the-array-foreach-call-nodelist-hack/)
* @param array the node list to browse
* @param callback the callback function
* @param scope the scope
*/
public static forEach(array, callback, scope?) {
for (var i = 0; i < array.length; i++) {
callback.call(scope, i, array[i]);
}
}
/**
* Inserts a DOM element after an other
* @param el the dom element to insert
* @param referenceNode the parent node to insert after
*/
public static insertAfter(el, referenceNode) {
referenceNode.parentNode.insertBefore(el, referenceNode.nextSibling);
}
}

View File

@ -0,0 +1,67 @@
import { IDynamicDataSource } from "@microsoft/sp-dynamic-data";
import { DynamicDataProvider } from "@microsoft/sp-component-base";
import IDynamicDataSourceConnection from "../models/IDynamicDataSourceConnection";
class DynamicDataHelper {
private _dynamicDataProvider: DynamicDataProvider;
private _instanceId: string;
private _componentId: string;
constructor(instanceId: string, componentId: string, dynamicDataProvider: DynamicDataProvider) {
this._instanceId = instanceId;
this._componentId = componentId;
this._dynamicDataProvider = dynamicDataProvider;
}
/**
* Gets a data source from its instance id or component id
* @param dataSourceInstanceId the data source instance id
*/
public _tryGetSourceByInstanceOrComponentId(dataSourceConnection: IDynamicDataSourceConnection): IDynamicDataSource {
// Try get source by instance id
const availableSources = this._dynamicDataProvider.getAvailableSources();
let sources: IDynamicDataSource[] = availableSources.filter((item) => {
if (item.metadata.instanceId) {
// Instance id is always unique and doesn't change for Web Parts when refreshing the page
// This is not the case for extensions
if (item.metadata.instanceId.localeCompare(dataSourceConnection.instanceId) === 0
&& this._instanceId !== item.metadata.instanceId) {
return true;
}
}
});
if (sources.length === 0 ) {
// Try get source by component id instead (SPFx extension)
sources = availableSources.filter((item) => {
if (item.metadata.instanceId) {
if (item.metadata.componentId.localeCompare(dataSourceConnection.componentId) === 0
&& this._componentId !== item.metadata.componentId) {
return true;
}
}
});
}
if (sources.length > 0 ) {
return sources[0];
} else {
return undefined;
}
}
/**
* Ensure a data source connection object is initialized with all needed properties
* @param dataSourceConnection the data source connection
*/
public isDataSourceInstanceInitialized(dataSourceConnection: IDynamicDataSourceConnection): boolean {
return dataSourceConnection.instanceId && dataSourceConnection.sourceId && dataSourceConnection.propertyId && dataSourceConnection.componentId ? true : false;
}
}
export default DynamicDataHelper;

View File

@ -0,0 +1,24 @@
interface IDynamicDataSourceConnection {
/**
* The source unique identifier
*/
sourceId: string;
/**
* The proeprty id (i.e. "searchResultsCount")
*/
propertyId: string;
/**
* The instance id
*/
instanceId: string;
/**
* The component id
*/
componentId: string;
}
export default IDynamicDataSourceConnection;

View File

@ -1,6 +1,7 @@
export interface ISearchResults { export interface ISearchResults {
RelevantResults: ISearchResult[]; RelevantResults: ISearchResult[];
RefinementResults: IRefinementResult[]; RefinementResults: IRefinementResult[];
PromotedResults?: IPromotedResult[];
TotalRows?: number; TotalRows?: number;
} }
@ -14,6 +15,12 @@ export interface IRefinementResult {
Values: IRefinementValue[]; Values: IRefinementValue[];
} }
export interface IPromotedResult {
Url: string;
Title: string;
Description: string;
}
export interface IRefinementValue { export interface IRefinementValue {
RefinementCount: number; RefinementCount: number;
RefinementName: string; RefinementName: string;

View File

@ -1,7 +1,6 @@
import ISearchService from './ISearchService'; import ISearchService from './ISearchService';
import { ISearchResults, IRefinementFilter, ISearchResult } from '../../models/ISearchResult'; import { ISearchResults, IRefinementFilter, ISearchResult } from '../../models/ISearchResult';
import intersection from 'lodash-es/intersection'; import { intersection, clone } from '@microsoft/sp-lodash-subset';
import clone from 'lodash-es/clone';
class MockSearchService implements ISearchService { class MockSearchService implements ISearchService {

View File

@ -1,15 +1,15 @@
import * as Handlebars from 'handlebars'; import * as Handlebars from 'handlebars';
import ISearchService from './ISearchService'; import ISearchService from './ISearchService';
import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter } from '../../models/ISearchResult'; import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter, IPromotedResult } from '../../models/ISearchResult';
import { sp, SearchQuery, SearchResults, SPRest, Web, Sort, SortDirection, SearchSuggestQuery } from '@pnp/sp'; import { sp, SearchQuery, SearchResults, SPRest, Sort, SortDirection, SearchSuggestQuery } from '@pnp/sp';
import { Logger, LogLevel, ConsoleListener } from '@pnp/logging'; import { Logger, LogLevel, ConsoleListener } from '@pnp/logging';
import { IWebPartContext } from '@microsoft/sp-webpart-base'; import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { Text } from '@microsoft/sp-core-library'; import { Text } from '@microsoft/sp-core-library';
import sortBy from 'lodash-es/sortBy'; import {sortBy, groupBy} from '@microsoft/sp-lodash-subset';
import groupBy from 'lodash-es/groupBy'; const mapKeys: any = require('lodash/mapKeys');
import mapValues from 'lodash-es/mapValues'; const mapValues: any = require('lodash/mapValues');
import mapKeys from 'lodash-es/mapKeys';
import LocalizationHelper from '../../helpers/LocalizationHelper'; import LocalizationHelper from '../../helpers/LocalizationHelper';
declare var System: any; declare var System: any;
class SearchService implements ISearchService { class SearchService implements ISearchService {
@ -218,6 +218,30 @@ class SearchService implements ISearchService {
}); });
}); });
// Query rules handling
const secondaryQueryResults = r2.RawSearchResults.SecondaryQueryResults;
if (Array.isArray(secondaryQueryResults) && secondaryQueryResults.length > 0) {
let promotedResults: IPromotedResult[] = [];
secondaryQueryResults.map((e) => {
// Best bets are mapped through the "SpecialTermResults" https://msdn.microsoft.com/en-us/library/dd907265(v=office.12).aspx
if (e.SpecialTermResults) {
e.SpecialTermResults.Results.map((result) => {
promotedResults.push({
Title: result.Title,
Url: result.Url,
Description: result.Description
} as IPromotedResult);
});
}
});
results.PromotedResults = promotedResults;
}
// Resolve all the promises once to get news // Resolve all the promises once to get news
const relevantResults: ISearchResult[] = await Promise.all(allItemsPromises); const relevantResults: ISearchResult[] = await Promise.all(allItemsPromises);
@ -283,18 +307,17 @@ class SearchService implements ISearchService {
private async _mapToIcon(filename: string): Promise<string> { private async _mapToIcon(filename: string): Promise<string> {
const webAbsoluteUrl = this._context.pageContext.web.absoluteUrl; const webAbsoluteUrl = this._context.pageContext.web.absoluteUrl;
const web = new Web(webAbsoluteUrl);
try { try {
const encodedFileName = filename ? filename.replace(/['']/g, '') : ''; const encodedFileName = filename ? filename.replace(/['']/g, '') : '';
const iconFileName = await web.mapToIcon(encodedFileName, 1); const iconFileName = await this._localPnPSetup.web.mapToIcon(encodedFileName, 1);
const iconUrl = webAbsoluteUrl + '/_layouts/15/images/' + iconFileName; const iconUrl = webAbsoluteUrl + '/_layouts/15/images/' + iconFileName;
return iconUrl; return iconUrl;
} catch (error) { } catch (error) {
Logger.write('[SharePointDataProvider._mapToIcon()]: Error: ' + error, LogLevel.Error); Logger.write('[SearchService._mapToIcon()]: Error: ' + error, LogLevel.Error);
throw error; throw new Error(error);
} }
} }
@ -327,12 +350,13 @@ class SearchService implements ISearchService {
let refinementQueryConditions: string[] = []; let refinementQueryConditions: string[] = [];
let refinementQueryString: string = null; let refinementQueryString: string = null;
// Conditions between values inside a refiner property
const refinementFilters = mapValues(groupBy(selectedFilters, 'FilterName'), (values) => { const refinementFilters = mapValues(groupBy(selectedFilters, 'FilterName'), (values) => {
const refinementFilter = values.map((filter) => { const refinementFilter = values.map((filter) => {
return filter.Value.RefinementToken; return filter.Value.RefinementToken;
}); });
return refinementFilter.length > 1 ? Text.format('or({0})', refinementFilter) : refinementFilter.toString(); return refinementFilter.length > 1 ? Text.format('and({0})', refinementFilter) : refinementFilter.toString();
}); });
mapKeys(refinementFilters, (value, key) => { mapKeys(refinementFilters, (value, key) => {
@ -357,6 +381,7 @@ class SearchService implements ISearchService {
// Multiple filters // Multiple filters
case (conditionsCount > 1): { case (conditionsCount > 1): {
// Conditions between refiner properties
refinementQueryString = Text.format('and({0})', refinementQueryConditions.toString()); refinementQueryString = Text.format('and({0})', refinementQueryConditions.toString());
break; break;
} }

View File

@ -0,0 +1,29 @@
.previewContainer {
&.videoPreview {
display: flex;
flex-direction: column;
z-index: 10;
}
&.documentPreview {
display: flex;
flex-direction: column;
z-index: 10;
}
.closeBtn {
padding-right: 10px;
padding-top: 10px;
text-align: right;
&:hover {
cursor: pointer;
}
}
}
.hoverIcon {
color: '[theme:themeDarker, default:#0078d7]';
}

View File

@ -4,6 +4,10 @@ import { html } from 'common-tags';
import { isEmpty } from '@microsoft/sp-lodash-subset'; import { isEmpty } from '@microsoft/sp-lodash-subset';
import * as strings from 'SearchWebPartStrings'; import * as strings from 'SearchWebPartStrings';
import { Text } from '@microsoft/sp-core-library'; import { Text } from '@microsoft/sp-core-library';
import 'video.js/dist/video-js.css';
import { Logger } from '@pnp/logging';
import templateStyles from './BaseTemplateService.module.scss';
import { DomHelper } from '../../helpers/DomHelper';
declare var System: any; declare var System: any;
abstract class BaseTemplateService { abstract class BaseTemplateService {
@ -35,21 +39,134 @@ abstract class BaseTemplateService {
*/ */
public static getListDefaultTemplate(): string { public static getListDefaultTemplate(): string {
return html` return html`
<style>
.template_listItem {
display:flex;
display: -ms-flexbox;
padding: 10px;
justify-content: space-between;
}
.template_listItem img.img-preview {
width: 120px;
opacity: 1;
display: block;
height: auto;
transition: .5s ease;
backface-visibility: hidden;
}
.template_result {
display: flex;
display: -ms-flexbox;
}
.template_listItem iframe, .template_listItem .video-js {
height: 250px;
margin: 10px;
}
.template_contentContainer {
display: flex;
width: 100%;
display: -ms-flexbox;
flex-direction: column;
margin-right: 15px;
}
.template_previewContainer {
align-items: center;
display: flex;
display: -ms-flexbox;
}
.template_icon {
height: 32px;
margin-right: 15px;
}
.hover {
transition: .5s ease;
opacity: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.img-container {
position: relative;
}
.img-container:hover img {
opacity: 0.2;
}
.img-container:hover .hover {
opacity: 1;
}
</style>
<div class="template_root"> <div class="template_root">
{{#if showResultsCount}} {{#if showResultsCount}}
<div class="template_resultCount"> <div class="template_resultCount">
<label class="ms-fontWeight-semibold">{{getCountMessage totalRows keywords}}</label> <label class="ms-fontWeight-semibold">{{getCountMessage totalRows keywords}}</label>
</div> </div>
{{/if}} {{/if}}
{{#if promotedResults}}
<ul class="ms-List template_defaultList template_promotedResults">
<li class="ms-fontWeight-semibold title">{{strings.PromotedResultsLabel}}</li>
{{#each promotedResults as |promotedResult|}}
<li class="ms-ListItem-primaryText">
<div>
<i class="ms-Icon ms-Icon--MiniLink" aria-hidden="true"></i>
</div>
<div>
<a class="ms-font-l" href="{{Url}}">{{Title}}</a>
<div class="ms-font-s">{{Description}}</div>
</div>
</li>
{{/each}}
</ul>
{{/if}}
<ul class="ms-List template_defaultList"> <ul class="ms-List template_defaultList">
{{#each items as |item|}} {{#each items as |item|}}
<li class="ms-ListItem ms-ListItem--image" tabindex="0"> <li class="template_listItem" tabindex="0">
<div class="ms-ListItem-image template_icon" style="background-image:url('{{iconSrc}}')"> <div class="template_result">
<img class="template_icon" src="{{iconSrc}}"/>
<div class="template_contentContainer">
<span class=""><a href="{{getUrl item}}">{{Title}}</a></span>
<span class="">{{getSummary HitHighlightedSummary}}</span>
<span class=""><span>{{getDate Created "LL"}}</span></span>
</div>
</div>
<div class="template_previewContainer ms-hiddenSm">
{{#eq item.contentclass compare='STS_ListItem_851'}}
<div class="video-container">
<div class="img-container">
<img id="preview_{{@index}}" class="img-preview video-preview-item" src="{{PictureThumbnailURL}}" data-url="{{DefaultEncodingURL}}" data-fileext="{{FileType}}"/>
<div class="hover">
<div class="${templateStyles.hoverIcon}"><i class="ms-Icon ms-Icon--ImageSearch" aria-hidden="true"></i></div>
</div>
</div>
</div>
{{/eq}}
{{#eq item.contentclass compare='STS_ListItem_DocumentLibrary'}}
{{#if ServerRedirectedPreviewURL}}
<div class="doc-container">
<div class="img-container">
<img id="preview_{{@index}}" class="img-preview document-preview-item" src="{{ServerRedirectedPreviewURL}}" data-url="{{ServerRedirectedEmbedURL}}"/>
<div class="hover">
<div class="${templateStyles.hoverIcon}"><i class="ms-Icon ms-Icon--ImageSearch" aria-hidden="true"></i></div>
</div>
</div>
</div>
{{/if}}
{{/eq}}
</div> </div>
<span class="ms-ListItem-primaryText"><a href="{{getUrl item}}">{{Title}}</a></span>
<span class="ms-ListItem-secondaryText">{{getSummary HitHighlightedSummary}}</span>
<span class="ms-ListItem-tertiaryText">{{getDate Created "LL"}}</span>
<div class="ms-ListItem-selectionTarget"></div>
</li> </li>
{{/each}} {{/each}}
</ul> </ul>
@ -207,9 +324,165 @@ abstract class BaseTemplateService {
return (pathExtension == '.htm' || pathExtension == '.html'); return (pathExtension == '.htm' || pathExtension == '.html');
} }
/**
* Initializes the previews on search results for documents and videos. Called when a template is updated/changed
*/
public initPreviewElements(): void {
this._initVideoPreviews();
this._initDocumentPreviews();
}
public abstract getFileContent(fileUrl: string): Promise<string>; public abstract getFileContent(fileUrl: string): Promise<string>;
public abstract ensureFileResolves(fileUrl: string): Promise<void>; public abstract ensureFileResolves(fileUrl: string): Promise<void>;
/**
* Gets the preview HTML element to render depending on the file type
* @param containerId the container id
* @param closeButtonId the close button id to be able to bind events on it
* @param innerHtml the content to render inside the container depending the file type
*/
private _getPreviewContainerElement(containerId: string, closeButtonId: string, innerHtml: string, containerClass: string): string {
return `
<div id="${containerId}" class="${containerClass} ms-bgColor-neutralLighter"}>
<i id="${closeButtonId}" class="ms-Icon ms-Icon--ChromeClose ${templateStyles.closeBtn}" aria-hidden="true"></i>
${innerHtml}
</div>
`;
}
private _initDocumentPreviews() {
const nodes = document.querySelectorAll('.document-preview-item');
DomHelper.forEach(nodes, ((index, el) => {
el.addEventListener("click", (event) => {
const thumbnailElt = event.srcElement;
// Get infos about the video to render
const url = event.srcElement.getAttribute("data-url");
const iframeId = `document_${event.target.id}`; // ex: 'document-preview-itemXXX';
const previewContainedId = `${iframeId}_container`;
let containerElt = document.getElementById(previewContainedId);
if (containerElt) {
thumbnailElt.parentElement.style.display= 'none';
containerElt.style.display= '';
} else {
if (url) {
thumbnailElt.parentElement.style.display= 'none';
const closeBtnId = `${iframeId}_closeBtn`;
const innerPreviewHtml = `
<iframe id="${iframeId}" class="iframePreview" src="${url}" frameborder="0">
</iframe>
`;
// Build the preview HTML element
const previewHtml = this._getPreviewContainerElement(previewContainedId, closeBtnId, innerPreviewHtml, `${templateStyles.previewContainer} ${templateStyles.documentPreview}`);
const newEl = document.createElement('div');
newEl.innerHTML = previewHtml;
DomHelper.insertAfter(newEl, thumbnailElt.parentElement);
document.getElementById(closeBtnId).addEventListener("click", ((event) => {
thumbnailElt.parentElement.style.display= '';
document.getElementById(previewContainedId).style.display= 'none';
}).bind(containerElt, thumbnailElt));
} else {
Logger.write(`The URL of the video was empty for the document. Make sure you've included the 'ServerRedirectedEmbedURL' property in the selected properties options in the Web Part property pane`);
}
}
});
}));
}
private async _initVideoPreviews() {
// Load Videos-Js on Demand
// Webpack will create a other bundle loaded on demand just for this library
const videoJs = await import(
/* webpackChunkName: 'videos-js' */
'video.js',
);
const Video = videoJs.default;
const nodes = document.querySelectorAll('.video-preview-item');
DomHelper.forEach(nodes, ((index, el) => {
el.addEventListener("click", (event) => {
const thumbnailElt = event.srcElement;
// Get infos about the video to render
const url = event.srcElement.getAttribute("data-url");
const fileExtension = event.srcElement.getAttribute("data-fileext");
const thumbnailSrc = event.srcElement.getAttribute("src");
const playerId = `video_${event.target.id}`; // ex: 'video-preview-itemXXX';
const previewContainedId = `${playerId}_container`;
let containerElt = document.getElementById(previewContainedId);
let player = Video.getPlayer(`#${playerId}`);
// Case when the player is still registered in Video.js but does not exist in the DOM (due to page mode switch or tempalte update)
if (player && !document.getElementById(playerId)) {
// In this case, we simply delete the player instance and recreate it
player.dispose();
player = Video.getPlayer(`#${playerId}`);
}
// Remove exiting instance if there is already a player registered with id
if (player) {
thumbnailElt.parentElement.style.display= 'none';
containerElt.style.display= '';
} else {
if (url && fileExtension) {
thumbnailElt.parentElement.style.display= 'none';
const closeBtnId = `${playerId}_closeBtn`;
const innerPreviewHtml = `
<video id="${playerId}" class="video-js vjs-big-play-centered">
<source src="${url}" type="video/${fileExtension}">
</video>
`;
// Build the preview HTML element
const previewHtml = this._getPreviewContainerElement(previewContainedId, closeBtnId, innerPreviewHtml, `${templateStyles.previewContainer} ${templateStyles.videoPreview}`);
const newEl = document.createElement('div');
newEl.innerHTML = previewHtml;
DomHelper.insertAfter(newEl, thumbnailElt.parentElement);
// Instantiate a new player with Video.js
const videoPlayer = new Video(playerId, {
controls: true,
autoplay: false,
preload: "metadata",
fluid: true,
poster: thumbnailSrc ? thumbnailSrc : null
});
document.getElementById(closeBtnId).addEventListener("click", ((ev) => {
thumbnailElt.parentElement.style.display= '';
if(!videoPlayer.paused()) {
videoPlayer.pause();
}
document.getElementById(previewContainedId).style.display = 'none';
}).bind(videoPlayer, thumbnailElt));
} else {
Logger.write(`The URL of the video was empty for the video. Make sure you've included the 'DefaultEncodingURL' property in the selected properties options in the Web Part property pane`);
}
}
});
}));
}
} }
export default BaseTemplateService; export default BaseTemplateService;

View File

@ -1,4 +1,5 @@
import { PageOpenBehavior } from '../../helpers/UrlHelper'; import { PageOpenBehavior } from '../../helpers/UrlHelper';
import IDynamicDataSourceConnection from '../../models/IDynamicDataSourceConnection';
interface ISearchBoxWebPartProps { interface ISearchBoxWebPartProps {
searchInNewPage: boolean; searchInNewPage: boolean;
@ -6,9 +7,7 @@ interface ISearchBoxWebPartProps {
openBehavior: PageOpenBehavior; openBehavior: PageOpenBehavior;
enableQuerySuggestions: boolean; enableQuerySuggestions: boolean;
useDynamicDataSource: boolean; useDynamicDataSource: boolean;
dynamicDataSourceId: string; sourceInstance: IDynamicDataSourceConnection;
dynamicDataSourcePropertyId: string;
dynamicDataSourceComponentId: string;
} }
export default ISearchBoxWebPartProps; export default ISearchBoxWebPartProps;

View File

@ -20,15 +20,16 @@ import { Log, Text, Environment, EnvironmentType } from
import ISearchService from '../../services/SearchService/ISearchService'; import ISearchService from '../../services/SearchService/ISearchService';
import MockSearchService from '../../services/SearchService/MockSearchService'; import MockSearchService from '../../services/SearchService/MockSearchService';
import SearchService from '../../services/SearchService/SearchService'; import SearchService from '../../services/SearchService/SearchService';
import DynamicDataHelper from '../../helpers/DynamicDataHelper';
const LOG_SOURCE: string = '[SearchBoxWebPart_{0}]';
export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWebPartProps> implements IDynamicDataCallables { export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWebPartProps> implements IDynamicDataCallables {
private readonly LOG_SOURCE: string = '[SearchBoxWebPart_{0}]';
private _searchService: ISearchService; private _searchService: ISearchService;
private _searchQuery: string; private _searchQuery: string;
private _source: IDynamicDataSource; private _source: IDynamicDataSource;
private _domElement: HTMLElement; private _dynamicDataHelper: DynamicDataHelper;
/** /**
* Used to be able to unregister dynamic data events if the source is updated * Used to be able to unregister dynamic data events if the source is updated
@ -50,38 +51,31 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
} }
/** /**
* Resolves the connected data sources * Binds data source properties to the Web Part properties. In some cases, the data source configuration is not retrieved propertly due to updated ids
* Useful in the case when the data source comes from an extension,
* the id is regenerated every time the page is refreshed causing the property pane configuration be lost
*/ */
private _initDynamicDataSource() { private _bindDataSources() {
if (this.properties.dynamicDataSourceId const sourceFound = this._source ? true : false;
&& this.properties.dynamicDataSourcePropertyId
&& this.properties.dynamicDataSourceComponentId) { if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId && !sourceFound) {
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
let sourceId = undefined; let sourceId = undefined;
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId);
if (this._source ) { if (this._source ) {
sourceId = this._source .id; sourceId = this._source .id;
} else { } else {
// Try to resolve the source and get its id by the name this._source = this._dynamicDataHelper._tryGetSourceByInstanceOrComponentId(this.properties.sourceInstance);
this._source = this._tryGetSourceByComponentId(this.properties.dynamicDataSourceComponentId);
sourceId = this._source ? this._source.id : undefined; sourceId = this._source ? this._source.id : undefined;
} }
if (sourceId) { if (sourceId) {
this.context.dynamicDataProvider.registerPropertyChanged(sourceId, this.properties.dynamicDataSourcePropertyId, this._dataSourceUpdated); this.context.dynamicDataProvider.registerPropertyChanged(sourceId, this.properties.sourceInstance.propertyId, this.render);
this._searchQuery = this._source.getPropertyValue(this.properties.dynamicDataSourcePropertyId);
// Update the property for the property pane // Update the property for the property pane
this.properties.dynamicDataSourceId = sourceId; this.properties.sourceInstance.sourceId = sourceId;
this._lastSourceId = this.properties.dynamicDataSourceId; this._lastSourceId = sourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId; this._lastPropertyId = this.properties.sourceInstance.propertyId;
// Notify subscriber of the initial value
this.context.dynamicDataSourceManager.notifyPropertyChanged('inputQuery');
// If false, means the onInit method is not completed yet so we let it render the web part through the normal process // If false, means the onInit method is not completed yet so we let it render the web part through the normal process
if (this.renderedOnce) { if (this.renderedOnce) {
@ -110,17 +104,21 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
return { return {
key: source.id, key: source.id,
text: source.metadata.title, text: source.metadata.title,
instanceId: source.metadata.instanceId,
componentId: source.metadata.componentId componentId: source.metadata.componentId
}; };
}).filter((item) => { }).filter((item) => {
if (item.key.localeCompare("PageContext") !== 0 && item.componentId !== this.componentId) { // We don't allow as data source:
// - The component itself
// - Components of the same type
if (item.instanceId !== this.instanceId && this.componentId !== item.componentId) {
return item; return item;
} }
}); });
const selectedSource: string = this.properties.dynamicDataSourceId; const selectedSource: string = this.properties.sourceInstance.sourceId;
let propertyOptions: IPropertyPaneDropdownOption[] = []; let propertyOptions: IPropertyPaneDropdownOption[] = [];
if (selectedSource) { if (selectedSource) {
const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource); const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource);
if (source) { if (source) {
@ -134,17 +132,17 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
} }
searchQueryConfigFields = searchQueryConfigFields.concat([ searchQueryConfigFields = searchQueryConfigFields.concat([
PropertyPaneDropdown('dynamicDataSourceId', { PropertyPaneDropdown('sourceInstance.sourceId', {
label: "Source", label: strings.DynamicDataSourceLabel,
options: sourceOptions, options: sourceOptions,
selectedKey: this.properties.dynamicDataSourceId, selectedKey: this.properties.sourceInstance.sourceId,
}), }),
PropertyPaneDropdown('dynamicDataSourcePropertyId', { PropertyPaneDropdown('sourceInstance.propertyId', {
disabled: !this.properties.dynamicDataSourceId, disabled: !this.properties.sourceInstance.sourceId,
label: "Source property", label: strings.DynamicDataSourcePropertyLabel,
options: propertyOptions, options: propertyOptions,
selectedKey: this.properties.dynamicDataSourcePropertyId selectedKey: this.properties.sourceInstance.propertyId
}), })
]); ]);
} }
@ -188,21 +186,6 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
return searchBehaviorOptionsFields; return searchBehaviorOptionsFields;
} }
/**
* Handler to notify data source subscribers the query string value has been updated
*/
private _dataSourceUpdated() {
if (this.properties.useDynamicDataSource) {
if (this.properties.dynamicDataSourceId && this.properties.dynamicDataSourcePropertyId) {
this._searchQuery = this._source ? this._source.getPropertyValue(this.properties.dynamicDataSourcePropertyId) : undefined;
this.context.dynamicDataSourceManager.notifyPropertyChanged('inputQuery');
this.render();
}
}
}
/** /**
* Verifies if the string is a correct URL * Verifies if the string is a correct URL
* @param value the URL to verify * @param value the URL to verify
@ -216,28 +199,6 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
return ''; return '';
} }
/**
* Gets a dynamic data source by its component id. The component id doesn't change when the page is refreshed
* @param dataSourceComponentId the component id
*/
private _tryGetSourceByComponentId(dataSourceComponentId: string): IDynamicDataSource {
const resolvedDataSource = this.context.dynamicDataProvider.getAvailableSources()
.filter((item) => {
if (item.metadata.componentId) {
if (item.metadata.componentId.localeCompare(dataSourceComponentId) === 0) {
return item;
}
}
});
if (resolvedDataSource.length > 0 ) {
return resolvedDataSource[0];
} else {
Log.verbose(Text.format(LOG_SOURCE, "_tryGetSourceByComponentId()"), `Unable to find dynamic data source with componentId '${dataSourceComponentId}'`);
return undefined;
}
}
/** /**
* Initializes the query suggestions data provider instance according to the current environnement * Initializes the query suggestions data provider instance according to the current environnement
*/ */
@ -253,51 +214,63 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
} }
} }
/**
* Make sure the data source will be plugged in correctly when refreshing the whole page
* In the cas of extension, the source id changes every time so we need to set the correct suorce Id to corresponding property to get the value at render time
*/
private _reconnectDataSource() {
if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId) {
this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._bindDataSources.bind(this));
}
}
protected onInit(): Promise<void> { protected onInit(): Promise<void> {
this._domElement = this.domElement; this._source = undefined;
if(!this.properties.sourceInstance) {
this.properties.sourceInstance = {
componentId: null,
instanceId: null,
propertyId: null,
sourceId: null
};
}
this.initSearchService(); this.initSearchService();
this.context.dynamicDataSourceManager.initializeSource(this); this.context.dynamicDataSourceManager.initializeSource(this);
// Make sure the data source will be plugged in correctly when loaded on the page // Re bind data sources to WebPart properties
// Depending of the component loading order, some sources may be unavailable at this time so that's why we use an event listener this._reconnectDataSource();
this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
return Promise.resolve(); return Promise.resolve();
} }
protected onPropertyPaneFieldChanged(changedProperty: string) { protected onPropertyPaneFieldChanged(propertyPath: string) {
this.initSearchService(); this.initSearchService();
if (changedProperty === 'dynamicDataSourceId') { if (propertyPath === 'sourceInstance.sourceId') {
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId); // Select the first property by default
this.properties.sourceInstance.propertyId =
this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId).getPropertyDefinitions()[0].id;
}
this.properties.dynamicDataSourcePropertyId = this._source.getPropertyDefinitions()[0].id; if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId) {
this.properties.dynamicDataSourceComponentId = this._source.metadata.componentId; this.context.dynamicDataProvider.registerPropertyChanged(this.properties.sourceInstance.sourceId, this.properties.sourceInstance.propertyId, this.render);
this._lastSourceId = this.properties.sourceInstance.sourceId;
this._lastPropertyId = this.properties.sourceInstance.propertyId;
}
// Unregister previous event listeners is the source is updated
if (this._lastSourceId && this._lastPropertyId) { if (this._lastSourceId && this._lastPropertyId) {
// Check if the source is still on the page so we can unregister
if (this.context.dynamicDataProvider.tryGetSource(this._lastSourceId)) { // In the case of extension, we don't need to unregister because the id changes every time the page is reloaded so it doesn't exist anymore
if (!this._lastSourceId.startsWith("Extension")) {
this.context.dynamicDataProvider.unregisterPropertyChanged(this._lastSourceId, this._lastPropertyId, this.render); this.context.dynamicDataProvider.unregisterPropertyChanged(this._lastSourceId, this._lastPropertyId, this.render);
} }
} }
this.context.dynamicDataProvider.registerPropertyChanged(this.properties.dynamicDataSourceId, this.properties.dynamicDataSourcePropertyId, this._dataSourceUpdated);
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
}
if (changedProperty === 'useDynamicDataSource') {
if (!this.properties.useDynamicDataSource) {
this.context.dynamicDataProvider.unregisterAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
}
}
} }
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration { protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
@ -341,6 +314,22 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
public render(): void { public render(): void {
if (this.properties.useDynamicDataSource) {
const needsConfiguration: boolean = !this.properties.sourceInstance.sourceId || !this.properties.sourceInstance.propertyId;
if (!needsConfiguration) {
const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId);
let sourceValue = source ? source.getPropertyValue(this.properties.sourceInstance.propertyId) : undefined;
if (typeof sourceValue === 'string') {
this._searchQuery = sourceValue ? sourceValue : "";
} else {
Log.warn(Text.format(this.LOG_SOURCE, this.instanceId), `The selected input value from the dynamic data source is not a string. Received (${typeof sourceValue})`, this.context.serviceScope);
}
}
}
const element: React.ReactElement<ISearchBoxContainerProps> = React.createElement( const element: React.ReactElement<ISearchBoxContainerProps> = React.createElement(
SearchBox, { SearchBox, {
onSearch: this._onSearch, onSearch: this._onSearch,
@ -352,6 +341,6 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
searchService: this._searchService searchService: this._searchService
} as ISearchBoxContainerProps); } as ISearchBoxContainerProps);
ReactDom.render(element, this._domElement); ReactDom.render(element, this.domElement);
} }
} }

View File

@ -10,7 +10,7 @@ import { IconType } from 'office-ui-fabric-react/lib
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { Label } from 'office-ui-fabric-react/lib/Label'; import { Label } from 'office-ui-fabric-react/lib/Label';
import * as update from 'immutability-helper'; import * as update from 'immutability-helper';
import '../SearchBoxWebPart.scss'; import styles from '../SearchBoxWebPart.module.scss';
const SUGGESTION_CHAR_COUNT_TRIGGER = 3; const SUGGESTION_CHAR_COUNT_TRIGGER = 3;
@ -125,9 +125,9 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
let suggestions: JSX.Element[] = null; let suggestions: JSX.Element[] = null;
if (this.state.isRetrievingSuggestions && this.state.proposedQuerySuggestions.length === 0) { if (this.state.isRetrievingSuggestions && this.state.proposedQuerySuggestions.length === 0) {
renderSuggestions = <div className="suggestionPanel"> renderSuggestions = <div className={styles.suggestionPanel}>
<div {...getItemProps({item: null, disabled: true})}> <div {...getItemProps({item: null, disabled: true})}>
<div className="suggestionItem"> <div className={styles.suggestionItem}>
<Spinner size={ SpinnerSize.small }/> <Spinner size={ SpinnerSize.small }/>
</div> </div>
</div> </div>
@ -142,13 +142,13 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
style={{ style={{
fontWeight: selectedItem === suggestion ? 'bold' : 'normal' fontWeight: selectedItem === suggestion ? 'bold' : 'normal'
}}> }}>
<Label className={ highlightedIndex === index ? 'suggestionItem selected': 'suggestionItem'}> <Label className={ highlightedIndex === index ? `${styles.suggestionItem} ${styles.selected}` : `${styles.suggestionItem}`}>
<div dangerouslySetInnerHTML={{ __html: suggestion }}></div> <div dangerouslySetInnerHTML={{ __html: suggestion }}></div>
</Label> </Label>
</div>; </div>;
}); });
renderSuggestions = <div className="suggestionPanel"> renderSuggestions = <div className={styles.suggestionPanel}>
{ suggestions } { suggestions }
</div>; </div>;
} }
@ -285,7 +285,7 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
errorMessage: null, errorMessage: null,
}); });
}} }}
className="errorMessage"> className={styles.errorMessage}>
{ this.state.errorMessage }</MessageBar>; { this.state.errorMessage }</MessageBar>;
} }
@ -293,7 +293,7 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
this.renderSearchBoxWithAutoComplete() : this.renderSearchBoxWithAutoComplete() :
this.renderBasicSearchBox(); this.renderBasicSearchBox();
return ( return (
<div className="searchBox"> <div className={styles.searchBox}>
{ renderErrorMessage } { renderErrorMessage }
{ renderSearchBox } { renderSearchBox }
</div> </div>

View File

@ -12,6 +12,8 @@ define([], function() {
"SearchBoxPageOpenBehaviorLabel": "Opening behavior", "SearchBoxPageOpenBehaviorLabel": "Opening behavior",
"SearchBoxDynamicPropertyInputLabel": "Input value", "SearchBoxDynamicPropertyInputLabel": "Input value",
"UseDynamicDataSourceLabel": "Use a dynamic data source as search query", "UseDynamicDataSourceLabel": "Use a dynamic data source as search query",
"SearchBoxDynamicDataSourceGroupName": "Dynamic data source configuration" "SearchBoxDynamicDataSourceGroupName": "Dynamic data source configuration",
"DynamicDataSourceLabel": "Available data sources",
"DynamicDataSourcePropertyLabel": "Available properties"
} }
}); });

View File

@ -10,6 +10,8 @@ define([], function() {
"SearchBoxPageOpenBehaviorLabel": "Mode d'ouverture de la page", "SearchBoxPageOpenBehaviorLabel": "Mode d'ouverture de la page",
"SearchBoxDynamicPropertyInputLabel": "Valeur du champ de recherche", "SearchBoxDynamicPropertyInputLabel": "Valeur du champ de recherche",
"UseDynamicDataSourceLabel": "Utiliser une source de données dynamique comme requête de recherche", "UseDynamicDataSourceLabel": "Utiliser une source de données dynamique comme requête de recherche",
"SearchBoxDynamicDataSourceGroupName": "Configuration de la source de données dynamique" "SearchBoxDynamicDataSourceGroupName": "Configuration de la source de données dynamique",
"DynamicDataSourceLabel": "Source de données disponibles",
"DynamicDataSourcePropertyLabel": "Propriétés disponibles"
} }
}); });

View File

@ -13,6 +13,8 @@ declare interface ISearchBoxWebPartStrings {
SearchBoxDynamicPropertyInputLabel: string; SearchBoxDynamicPropertyInputLabel: string;
UseDynamicDataSourceLabel: string; UseDynamicDataSourceLabel: string;
SearchBoxDynamicDataSourceGroupName: string; SearchBoxDynamicDataSourceGroupName: string;
DynamicDataSourceLabel: string;
DynamicDataSourcePropertyLabel: string;
} }
declare module 'SearchBoxWebPartStrings' { declare module 'SearchBoxWebPartStrings' {

View File

@ -1,4 +1,5 @@
import ResultsLayoutOption from '../../models/ResultsLayoutOption'; import ResultsLayoutOption from '../../models/ResultsLayoutOption';
import IDynamicDataSourceConnection from '../../models/IDynamicDataSourceConnection';
export interface ISearchResultsWebPartProps { export interface ISearchResultsWebPartProps {
queryKeywords: string; queryKeywords: string;
@ -16,9 +17,7 @@ export interface ISearchResultsWebPartProps {
selectedLayout: ResultsLayoutOption; selectedLayout: ResultsLayoutOption;
externalTemplateUrl: string; externalTemplateUrl: string;
inlineTemplateText: string; inlineTemplateText: string;
dynamicDataSourceId: string;
dynamicDataSourcePropertyId: string;
dynamicDataSourceComponentId: string;
useHandlebarsHelpers: boolean; useHandlebarsHelpers: boolean;
webPartTitle: string; webPartTitle: string;
sourceInstance: IDynamicDataSourceConnection;
} }

View File

@ -25,7 +25,7 @@
"queryKeywords": "", "queryKeywords": "",
"queryTemplate": "{searchTerms} Path:{Site}", "queryTemplate": "{searchTerms} Path:{Site}",
"refiners": "Created:\"Created Date\",Size:\"Size of the file\"", "refiners": "Created:\"Created Date\",Size:\"Size of the file\"",
"selectedProperties": "Title,Path,Created,Filename,SiteLogo,PreviewUrl,PictureThumbnailURL,ServerRedirectedPreviewURL,ServerRedirectedURL,HitHighlightedSummary", "selectedProperties": "Title,Path,Created,Filename,SiteLogo,PreviewUrl,PictureThumbnailURL,ServerRedirectedPreviewURL,ServerRedirectedURL,HitHighlightedSummary,FileType,contentclass,ServerRedirectedEmbedURL,DefaultEncodingURL",
"enableQueryRules": false, "enableQueryRules": false,
"maxResultsCount": 10, "maxResultsCount": 10,
"showBlank": true, "showBlank": true,

View File

@ -37,21 +37,22 @@ import { update, isEmpty } from '@microsoft/sp-lodash-subset';
import MockTemplateService from '../../services/TemplateService/MockTemplateService'; import MockTemplateService from '../../services/TemplateService/MockTemplateService';
import BaseTemplateService from '../../services/TemplateService/BaseTemplateService'; import BaseTemplateService from '../../services/TemplateService/BaseTemplateService';
import { IDynamicDataSource } from '@microsoft/sp-dynamic-data'; import { IDynamicDataSource } from '@microsoft/sp-dynamic-data';
import DynamicDataHelper from '../../helpers/DynamicDataHelper';
declare var System: any; declare var System: any;
const LOG_SOURCE: string = '[SearchResultsWebPart_{0}]';
export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchResultsWebPartProps> { export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchResultsWebPartProps> {
private readonly LOG_SOURCE: string = '[SearchResultsWebPart_{0}]';
private _searchService: ISearchService; private _searchService: ISearchService;
private _taxonomyService: ITaxonomyService; private _taxonomyService: ITaxonomyService;
private _templateService: BaseTemplateService; private _templateService: BaseTemplateService;
private _useResultSource: boolean; private _useResultSource: boolean;
private _queryKeywords: string; private _queryKeywords: string;
private _source: IDynamicDataSource; private _source: IDynamicDataSource;
private _domElement: HTMLElement;
private _propertyPage = null; private _propertyPage = null;
private _dynamicDataHelper: DynamicDataHelper;
/** /**
* Used to be able to unregister dynamic data events if the source is updated * Used to be able to unregister dynamic data events if the source is updated
@ -71,34 +72,31 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
} }
/** /**
* Resolves the connected data sources * Binds data source properties to the Web Part properties. In some cases, the data source configuration is not retrieved propertly due to updated ids
* Useful in the case when the data source comes from an extension,
* the id is regenerated every time the page is refreshed causing the property pane configuration be lost
*/ */
private _initDynamicDataSource() { private _bindDataSources() {
if (this.properties.dynamicDataSourceId const sourceFound = this._source ? true : false;
&& this.properties.dynamicDataSourcePropertyId
&& this.properties.dynamicDataSourceComponentId) { if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId && !sourceFound) {
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
let sourceId = undefined; let sourceId = undefined;
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId);
if (this._source ) { if (this._source ) {
sourceId = this._source .id; sourceId = this._source .id;
} else { } else {
// Try to resolve the source and get its id by the name this._source = this._dynamicDataHelper._tryGetSourceByInstanceOrComponentId(this.properties.sourceInstance);
this._source = this._tryGetSourceByComponentId(this.properties.dynamicDataSourceComponentId);
sourceId = this._source ? this._source.id : undefined; sourceId = this._source ? this._source.id : undefined;
} }
if (sourceId) { if (sourceId) {
this.context.dynamicDataProvider.registerPropertyChanged(sourceId, this.properties.dynamicDataSourcePropertyId, this.render); this.context.dynamicDataProvider.registerPropertyChanged(sourceId, this.properties.sourceInstance.propertyId, this.render);
// Update the property for the property pane // Update the property for the property pane
this.properties.dynamicDataSourceId = sourceId; this.properties.sourceInstance.sourceId = sourceId;
this._lastSourceId = this.properties.dynamicDataSourceId; this._lastSourceId = sourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId; this._lastPropertyId = this.properties.sourceInstance.propertyId;
// If false, means the onInit method is not completed yet so we let it render the web part through the normal process // If false, means the onInit method is not completed yet so we let it render the web part through the normal process
if (this.renderedOnce) { if (this.renderedOnce) {
@ -109,26 +107,15 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
} }
/** /**
* Gets a dynamic data source by its component id. The component id doesn't change when the page is refreshed * Make sure the data source will be plugged in correctly when refreshing the whole page
* @param dataSourceComponentId the component id * In the cas of extension, the source id changes every time so we need to set the correct suorce Id to corresponding property to get the value at render time
*/ */
private _tryGetSourceByComponentId(dataSourceComponentId: string): IDynamicDataSource { private _reconnectDataSource() {
const resolvedDataSource = this.context.dynamicDataProvider.getAvailableSources() if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId) {
.filter((item) => { this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._bindDataSources.bind(this));
if (item.metadata.componentId) {
if (item.metadata.componentId.localeCompare(dataSourceComponentId) === 0) {
return item;
} }
} }
});
if (resolvedDataSource.length > 0) {
return resolvedDataSource[0];
} else {
Log.verbose(Text.format(LOG_SOURCE, "_tryGetSourceByComponentId()"), `Unable to find dynamic data source with componentId '${dataSourceComponentId}'`);
return undefined;
}
}
/** /**
* Determines the group fields for the search settings options inside the property pane * Determines the group fields for the search settings options inside the property pane
@ -178,7 +165,15 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
resizable: true, resizable: true,
value: this.properties.refiners, value: this.properties.refiners,
deferredValidationTime: 300, deferredValidationTime: 300,
}) }),
PropertyPaneSlider('maxResultsCount', {
label: strings.MaxResultsCount,
max: 50,
min: 1,
showValue: true,
step: 1,
value: 50,
}),
]; ];
return searchSettingsFields; return searchSettingsFields;
@ -201,13 +196,22 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
this.context.dynamicDataProvider.getAvailableSources().map(source => { this.context.dynamicDataProvider.getAvailableSources().map(source => {
return { return {
key: source.id, key: source.id,
text: source.metadata.title text: source.metadata.title,
instanceId: source.metadata.instanceId,
componentId: source.metadata.componentId
}; };
}).filter(item => item.key.localeCompare("PageContext") !== 0); }).filter((item) => {
// We don't allow as data source:
const selectedSource: string = this.properties.dynamicDataSourceId; // - The component itself
// - Components of the same type
if (item.instanceId !== this.instanceId && this.componentId !== item.componentId) {
return item;
}
});
const selectedSource: string = this.properties.sourceInstance.sourceId;
let propertyOptions: IPropertyPaneDropdownOption[] = []; let propertyOptions: IPropertyPaneDropdownOption[] = [];
if (selectedSource) { if (selectedSource) {
const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource); const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource);
if (source) { if (source) {
@ -221,17 +225,17 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
} }
searchQueryConfigFields = searchQueryConfigFields.concat([ searchQueryConfigFields = searchQueryConfigFields.concat([
PropertyPaneDropdown('dynamicDataSourceId', { PropertyPaneDropdown('sourceInstance.sourceId', {
label: "Source", label: strings.DynamicDataSourceLabel,
options: sourceOptions, options: sourceOptions,
selectedKey: this.properties.dynamicDataSourceId, selectedKey: this.properties.sourceInstance.sourceId,
}), }),
PropertyPaneDropdown('dynamicDataSourcePropertyId', { PropertyPaneDropdown('sourceInstance.propertyId', {
disabled: !this.properties.dynamicDataSourceId, disabled: !this.properties.sourceInstance.sourceId,
label: "Source property", label: strings.DynamicDataSourcePropertyLabel,
options: propertyOptions, options: propertyOptions,
selectedKey: this.properties.dynamicDataSourcePropertyId selectedKey: this.properties.sourceInstance.propertyId
}), })
]); ]);
} else { } else {
@ -249,17 +253,6 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
}) })
); );
} }
searchQueryConfigFields.push(
PropertyPaneLabel('', { text: '' }), // dummy space
PropertyPaneSlider('maxResultsCount', {
label: strings.MaxResultsCount,
max: 50,
min: 1,
showValue: true,
step: 1,
value: 50,
})
);
return searchQueryConfigFields; return searchQueryConfigFields;
} }
@ -514,7 +507,16 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
*/ */
protected async onInit(): Promise<void> { protected async onInit(): Promise<void> {
this._domElement = this.domElement; this._dynamicDataHelper = new DynamicDataHelper(this.instanceId, this.componentId, this.context.dynamicDataProvider);
if(!this.properties.sourceInstance) {
this.properties.sourceInstance = {
componentId: null,
instanceId: null,
propertyId: null,
sourceId: null
};
}
if (Environment.type === EnvironmentType.Local) { if (Environment.type === EnvironmentType.Local) {
this._searchService = new MockSearchService(); this._searchService = new MockSearchService();
@ -537,9 +539,7 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
// Set the default search results layout // Set the default search results layout
this.properties.selectedLayout = this.properties.selectedLayout ? this.properties.selectedLayout : ResultsLayoutOption.List; this.properties.selectedLayout = this.properties.selectedLayout ? this.properties.selectedLayout : ResultsLayoutOption.List;
// Make sure the data source will be plugged in correctly when loaded on the page this._reconnectDataSource();
// Depending of the component loading order, some sources may be unavailable at this time so that's why we use an event listener
this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
return super.onInit(); return super.onInit();
} }
@ -564,8 +564,8 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
const searchContainer: React.ReactElement<ISearchContainerProps> = React.createElement( const searchContainer: React.ReactElement<ISearchContainerProps> = React.createElement(
SearchContainer, SearchContainer,
{ {
searchDataProvider: this._searchService, searchService: this._searchService,
taxonomyDataProvider: this._taxonomyService, taxonomyService: this._taxonomyService,
queryKeywords: this._queryKeywords, queryKeywords: this._queryKeywords,
maxResultsCount: this.properties.maxResultsCount, maxResultsCount: this.properties.maxResultsCount,
resultSourceId: this.properties.resultSourceId, resultSourceId: this.properties.resultSourceId,
@ -596,7 +596,7 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
); );
if ((this.properties.queryKeywords && !this.properties.useSearchBoxQuery) || if ((this.properties.queryKeywords && !this.properties.useSearchBoxQuery) ||
(this.properties.useSearchBoxQuery && this.properties.dynamicDataSourcePropertyId)) { (this.properties.useSearchBoxQuery && this.properties.sourceInstance.sourceId)) {
renderElement = searchContainer; renderElement = searchContainer;
} else { } else {
if (this.displayMode === DisplayMode.Edit) { if (this.displayMode === DisplayMode.Edit) {
@ -606,7 +606,7 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
} }
} }
ReactDom.render(renderElement, this._domElement); ReactDom.render(renderElement, this.domElement);
} }
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration { protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
@ -654,20 +654,53 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
); );
} }
public async onPropertyPaneFieldChanged(changedProperty: string) { public async onPropertyPaneFieldChanged(propertyPath: string) {
if (changedProperty === 'useSearchBoxQuery') { if (propertyPath === 'useSearchBoxQuery') {
if (!this.properties.useSearchBoxQuery) { if (!this.properties.useSearchBoxQuery) {
// Reset source settings if we don't use search query // Reset source settings if we don't use search query
this.properties.dynamicDataSourceId = undefined; this.properties.sourceInstance.sourceId = undefined;
this.properties.dynamicDataSourcePropertyId = undefined; this.properties.sourceInstance.propertyId = undefined;
this.context.dynamicDataProvider.unregisterAvailableSourcesChanged(this._initDynamicDataSource.bind(this)); this.properties.sourceInstance.instanceId = undefined;
this.properties.sourceInstance.componentId = undefined;
if (this._lastSourceId) {
if (!this._lastSourceId.startsWith("Extension")) {
this.context.dynamicDataProvider.unregisterPropertyChanged(this._lastSourceId, this._lastPropertyId, this.render);
} }
} }
} else {
// Reset search query
this.properties.queryKeywords = undefined;
}
}
if (propertyPath === 'sourceInstance.sourceId') {
// Select the first property by default
this.properties.sourceInstance.propertyId =
this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId).getPropertyDefinitions()[0].id;
}
if (this._lastSourceId && this._lastPropertyId) {
// In the case of extension, we don't need to unregister because the id changes every time the page is reloaded so it doesn't exist anymore
if (!this._lastSourceId.startsWith("Extension")) {
this.context.dynamicDataProvider.unregisterPropertyChanged(this._lastSourceId, this._lastPropertyId, this.render);
}
}
if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId) {
this.context.dynamicDataProvider.registerPropertyChanged(this.properties.sourceInstance.sourceId, this.properties.sourceInstance.propertyId, this.render);
this._lastSourceId = this.properties.sourceInstance.sourceId;
this._lastPropertyId = this.properties.sourceInstance.propertyId;
}
// Detect if the layout has been changed to custom... // Detect if the layout has been changed to custom...
if (changedProperty === 'inlineTemplateText') { if (propertyPath === 'inlineTemplateText') {
// Automatically switch the option to 'Custom' if a default template has been edited // Automatically switch the option to 'Custom' if a default template has been edited
// (meaning the user started from a the list or tiles template) // (meaning the user started from a the list or tiles template)
@ -679,27 +712,6 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
} }
} }
if (changedProperty === 'dynamicDataSourceId') {
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
this.properties.dynamicDataSourcePropertyId = this._source.getPropertyDefinitions()[0].id;
this.properties.dynamicDataSourceComponentId = this._source.metadata.componentId;
// Unregister previous event listeners is the source is updated
if (this._lastSourceId && this._lastPropertyId) {
// Check if the source is still on the page so we can unregister
if (this.context.dynamicDataProvider.tryGetSource(this._lastSourceId)) {
this.context.dynamicDataProvider.unregisterPropertyChanged(this._lastSourceId, this._lastPropertyId, this.render);
}
}
this.context.dynamicDataProvider.registerPropertyChanged(this.properties.dynamicDataSourceId, this.properties.dynamicDataSourcePropertyId, this.render);
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
}
await this._templateService.LoadHandlebarsHelpers(this.properties.useHandlebarsHelpers); await this._templateService.LoadHandlebarsHelpers(this.properties.useHandlebarsHelpers);
} }
@ -716,8 +728,18 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
// If a source is selected, use the value from here // If a source is selected, use the value from here
if (this.properties.useSearchBoxQuery) { if (this.properties.useSearchBoxQuery) {
if (this.properties.dynamicDataSourceId && this.properties.dynamicDataSourcePropertyId) {
this._queryKeywords = this._source ? this._source.getPropertyValue(this.properties.dynamicDataSourcePropertyId) : this._queryKeywords; const needsConfiguration: boolean = !this.properties.sourceInstance.sourceId || !this.properties.sourceInstance.propertyId;
if (!needsConfiguration) {
const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId);
let sourceValue = source ? source.getPropertyValue(this.properties.sourceInstance.propertyId) : undefined;
if (typeof sourceValue === 'string') {
this._queryKeywords = sourceValue ? sourceValue : "";
} else {
Log.warn(Text.format(this.LOG_SOURCE, this.instanceId), `The selected input value from the dynamic data source is not a string. Received (${typeof sourceValue})`, this.context.serviceScope);
}
} }
} }

View File

@ -3,12 +3,10 @@ import IFilterPanelProps from './IFilte
import IFilterPanelState from './IFilterPanelState'; import IFilterPanelState from './IFilterPanelState';
import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel'; import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel';
import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox';
import { Toggle } from 'office-ui-fabric-react/lib/Toggle';
import * as strings from 'SearchWebPartStrings'; import * as strings from 'SearchWebPartStrings';
import { IRefinementValue, IRefinementFilter } from '../../../../models/ISearchResult'; import { IRefinementValue, IRefinementFilter } from '../../../../models/ISearchResult';
import { Label } from 'office-ui-fabric-react/lib/Label'; import { Label } from 'office-ui-fabric-react/lib/Label';
import { Text } from '@microsoft/sp-core-library'; import { Text } from '@microsoft/sp-core-library';
import '../SearchResultsWebPart.scss';
import * as update from 'immutability-helper'; import * as update from 'immutability-helper';
import { import {
GroupedList, GroupedList,
@ -16,7 +14,8 @@ import {
IGroupDividerProps IGroupDividerProps
} from 'office-ui-fabric-react/lib/components/GroupedList/index'; } from 'office-ui-fabric-react/lib/components/GroupedList/index';
import { Scrollbars } from 'react-custom-scrollbars'; import { Scrollbars } from 'react-custom-scrollbars';
import { ActionButton } from 'office-ui-fabric-react/lib/Button'; import {ActionButton, Link} from 'office-ui-fabric-react';
import styles from '../SearchResultsWebPart.module.scss';
export default class FilterPanel extends React.Component<IFilterPanelProps, IFilterPanelState> { export default class FilterPanel extends React.Component<IFilterPanelProps, IFilterPanelState> {
@ -34,7 +33,6 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
this._addFilter = this._addFilter.bind(this); this._addFilter = this._addFilter.bind(this);
this._removeFilter = this._removeFilter.bind(this); this._removeFilter = this._removeFilter.bind(this);
this._isInFilterSelection = this._isInFilterSelection.bind(this); this._isInFilterSelection = this._isInFilterSelection.bind(this);
this._applyAllfilters = this._applyAllfilters.bind(this);
this._removeAllFilters = this._removeAllFilters.bind(this); this._removeAllFilters = this._removeAllFilters.bind(this);
this._onRenderHeader = this._onRenderHeader.bind(this); this._onRenderHeader = this._onRenderHeader.bind(this);
this._onRenderCell = this._onRenderCell.bind(this); this._onRenderCell = this._onRenderCell.bind(this);
@ -61,7 +59,6 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
items.push( items.push(
<div key={i}> <div key={i}>
<div className='filterPanel__filterProperty'>
{ {
filter.Values.map((refinementValue: IRefinementValue, j) => { filter.Values.map((refinementValue: IRefinementValue, j) => {
@ -85,14 +82,13 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
}) })
} }
</div> </div>
</div>
); );
}); });
const renderSelectedFilters: JSX.Element[] = this.state.selectedFilters.map((filter) => { const renderSelectedFilters: JSX.Element[] = this.state.selectedFilters.map((filter) => {
return ( return (
<Label className='filter'> <Label className={styles.filter}>
<i className='ms-Icon ms-Icon--ClearFilter' onClick={() => { this._removeFilter(filter); }}></i> <i className='ms-Icon ms-Icon--ClearFilter' onClick={() => { this._removeFilter(filter); }}></i>
{filter.Value.RefinementName} {filter.Value.RefinementName}
</Label> </Label>
@ -103,7 +99,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
ref='groupedList' ref='groupedList'
items={items} items={items}
onRenderCell={this._onRenderCell} onRenderCell={this._onRenderCell}
className='filterPanel__body__group' className={styles.searchWp__filterPanel__body__group}
groupProps={ groupProps={
{ {
onRenderHeader: this._onRenderHeader, onRenderHeader: this._onRenderHeader,
@ -111,46 +107,47 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
} }
groups={groups} />; groups={groups} />;
const renderLinkRemoveAll = this.state.selectedFilters.length > 0 ?
<Link onClick={this._removeAllFilters}>
{strings.RemoveAllFiltersLabel}
</Link> : null;
return ( return (
<div> <div>
<div className="ms-textAlignRight">
<ActionButton <ActionButton
className='searchWp__filterResultBtn' className={`${styles.searchWp__filterResultBtn} ms-fontWeight-semibold`}
iconProps={{ iconName: 'Filter' }} iconProps={{ iconName: 'Filter' }}
text={strings.FilterResultsButtonLabel} text={strings.FilterResultsButtonLabel}
onClick={this._onTogglePanel} onClick={this._onTogglePanel}
/> />
</div>
{(this.state.selectedFilters.length > 0) ? {(this.state.selectedFilters.length > 0) ?
<div className='searchWp__selectedFilters'> <div className={styles.searchWp__selectedFilters}>
{renderSelectedFilters} {renderSelectedFilters}
</div> </div>
: null : null
} }
<Panel <Panel
className='filterPanel' className={styles.searchWp__filterPanel}
isOpen={this.state.showPanel} isOpen={this.state.showPanel}
type={PanelType.smallFixedNear} type={PanelType.custom}
customWidth="450px"
isBlocking={false} isBlocking={false}
isLightDismiss={true} isLightDismiss={true}
onDismiss={this._onClosePanel} onDismiss={this._onClosePanel}
headerText={strings.FilterPanelTitle} headerText={strings.FilterPanelTitle}
closeButtonAriaLabel='Close' closeButtonAriaLabel='Close'
hasCloseButton={true} hasCloseButton={true}
headerClassName='filterPanel__header'
onRenderBody={() => { onRenderBody={() => {
if (this.props.availableFilters.length > 0) { if (this.props.availableFilters.length > 0) {
return ( return (
<Scrollbars style={{height: '100%'}}> <Scrollbars style={{height: '100%'}}>
<div className='filterPanel__body'> <div className={styles.searchWp__filterPanel__body}>
<div className='filterPanel__body__allFiltersToggle'> <div
<Toggle className={`${styles.searchWp__filterPanel__body__allFiltersToggle} ${this.state.selectedFilters.length == 0 && "hiddenLink"}`}>
onText={strings.RemoveAllFiltersLabel} {renderLinkRemoveAll}
offText={strings.ApplyAllFiltersLabel}
onChanged={(checked: boolean) => {
checked ? this._applyAllfilters() : this._removeAllFilters();
}}
checked={this.state.selectedFilters.length === 0 ? false : true}
/>
</div> </div>
{renderAvailableFilters} {renderAvailableFilters}
</div> </div>
@ -158,7 +155,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
); );
} else { } else {
return ( return (
<div className='filterPanel__body'> <div className={styles.searchWp__filterPanel__body}>
{strings.NoFilterConfiguredLabel} {strings.NoFilterConfiguredLabel}
</div> </div>
); );
@ -169,6 +166,22 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
); );
} }
public componentDidMount() {
this.setState({
selectedFilters: []
});
}
public componentWillReceiveProps(nextProps: IFilterPanelProps) {
if (nextProps.resetSelectedFilters) {
// Reset the selected filter on new query
this.setState({
selectedFilters: []
});
}
}
private _onRenderCell(nestingDepth: number, item: any, itemIndex: number) { private _onRenderCell(nestingDepth: number, item: any, itemIndex: number) {
return ( return (
<div className='ms-Grid-row' data-selection-index={itemIndex}> <div className='ms-Grid-row' data-selection-index={itemIndex}>
@ -181,7 +194,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
private _onRenderHeader(props: IGroupDividerProps): JSX.Element { private _onRenderHeader(props: IGroupDividerProps): JSX.Element {
return ( return (
<div className={styles.searchWp__filterPanel__body__group__header}>
<div className='ms-Grid-row' onClick={() => { <div className='ms-Grid-row' onClick={() => {
// Update the index for expanded groups to be able to keep it open after a re-render // Update the index for expanded groups to be able to keep it open after a re-render
@ -197,7 +210,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
props.onToggleCollapse(props.group); props.onToggleCollapse(props.group);
}}> }}>
<div className='ms-Grid-col ms-u-sm1 ms-u-md1 ms-u-lg1'> <div className='ms-Grid-col ms-u-sm1 ms-u-md1 ms-u-lg1'>
<div className='header-icon'> <div className={styles.searchWp__filterPanel__body__headerIcon}>
<i className={props.group.isCollapsed ? 'ms-Icon ms-Icon--ChevronDown' : 'ms-Icon ms-Icon--ChevronUp'}></i> <i className={props.group.isCollapsed ? 'ms-Icon ms-Icon--ChevronDown' : 'ms-Icon ms-Icon--ChevronUp'}></i>
</div> </div>
</div> </div>
@ -205,6 +218,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
<div className='ms-font-l'>{props.group.name}</div> <div className='ms-font-l'>{props.group.name}</div>
</div> </div>
</div> </div>
</div>
); );
} }
@ -233,20 +247,6 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
this._applyFilters(newFilters); this._applyFilters(newFilters);
} }
private _applyAllfilters(): void {
let allFilters: IRefinementFilter[] = [];
this.props.availableFilters.map((filter) => {
filter.Values.map((refinementValue: IRefinementValue, index) => {
allFilters.push({ FilterName: filter.FilterName, Value: refinementValue });
});
});
this._applyFilters(allFilters);
}
private _removeAllFilters(): void { private _removeAllFilters(): void {
this._applyFilters([]); this._applyFilters([]);
} }

View File

@ -5,6 +5,7 @@ interface IFilterPanelProps {
availableFilters: IRefinementResult[]; availableFilters: IRefinementResult[];
refinersConfiguration: { [key: string]: string }; refinersConfiguration: { [key: string]: string };
onUpdateFilters: RefinementFilterOperationCallback; onUpdateFilters: RefinementFilterOperationCallback;
resetSelectedFilters: boolean;
} }
export default IFilterPanelProps; export default IFilterPanelProps;

View File

@ -1,10 +1,12 @@
import { ISearchResult } from '../../../../models/ISearchResult'; import { ISearchResult, IPromotedResult } from '../../../../models/ISearchResult';
/** /**
* Handlebars template context for search results * Handlebars template context for search results
*/ */
interface ISearchResultsTemplateContext { interface ISearchResultsTemplateContext {
items: ISearchResult[]; items: ISearchResult[];
promotedResults?: IPromotedResult[];
strings: ISearchWebPartStrings;
totalRows: number; totalRows: number;
keywords: string; keywords: string;
showResultsCount: boolean; showResultsCount: boolean;

View File

@ -1,4 +1,8 @@
interface ISearchResultsTemplateState { interface ISearchResultsTemplateState {
/**
* The handlebar compiled template
*/
processedTemplate: string; processedTemplate: string;
} }

View File

@ -1,4 +1,5 @@
.searchWp {
.template_root {
@import '~office-ui-fabric/dist/sass/Fabric.scss'; @import '~office-ui-fabric/dist/sass/Fabric.scss';
@import '~office-ui-fabric/dist/components/Label/Label.scss'; @import '~office-ui-fabric/dist/components/Label/Label.scss';
@import '~office-ui-fabric/dist/components/List/List.scss'; @import '~office-ui-fabric/dist/components/List/List.scss';
@ -6,14 +7,33 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/FabricCore.scss'; @import '~@microsoft/sp-office-ui-fabric-core/dist/sass/FabricCore.scss';
.template_defaultList { .template_defaultList {
.template_icon {
background-position: top;
background-repeat: no-repeat;
}
strong { strong {
color: "[theme: themePrimary]" color: "[theme: themePrimary, default: #005a9e]";
} }
} }
.template_promotedResults {
border: 1px solid #eaeaea;
list-style: none;
padding: 15px;
margin-bottom: 15px;
.title {
margin-bottom: 15px;
}
li {
display: flex;
white-space: normal;
}
i {
padding-right: 10px;
padding-left: 10px;
}
}
.template_defaultCard { .template_defaultCard {
.singleCard { .singleCard {
margin: 10px; margin: 10px;
@ -42,6 +62,7 @@
cursor: pointer; cursor: pointer;
} }
} }
.template_resultCount { .template_resultCount {
padding-left: 10px; padding-left: 10px;
margin-bottom: 10px; margin-bottom: 10px;

View File

@ -2,24 +2,46 @@ import React = require('react');
import ISearchResultsTemplateProps from './ISearchResultsTemplateProps'; import ISearchResultsTemplateProps from './ISearchResultsTemplateProps';
import ISearchResultsTemplateState from './ISearchResultsTemplateState'; import ISearchResultsTemplateState from './ISearchResultsTemplateState';
import './SearchResultsTemplate.scss'; import './SearchResultsTemplate.scss';
import { Resize } from 'on-el-resize';
import { DomHelper } from '../../../../helpers/DomHelper';
export default class SearchResultsTemplate extends React.Component<ISearchResultsTemplateProps, ISearchResultsTemplateState> { export default class SearchResultsTemplate extends React.Component<ISearchResultsTemplateProps, ISearchResultsTemplateState> {
private parentRef: HTMLElement;
private resize: Resize;
constructor() { constructor() {
super(); super();
this.resize = new Resize();
this.state = { this.state = {
processedTemplate: null processedTemplate: null
}; };
this.onComponentResize = this.onComponentResize.bind(this);
} }
public render() { public render() {
return <div dangerouslySetInnerHTML={{ __html: this.state.processedTemplate }}></div>; return <div ref={el => this.parentRef = el}>
<div dangerouslySetInnerHTML={{ __html: this.state.processedTemplate }}></div>
</div>;
}
public componentWillUnmount() {
this.resize.removeResizeListener(this.parentRef, this.onComponentResize);
} }
public componentDidMount() { public componentDidMount() {
this._updateTemplate(this.props); this._updateTemplate(this.props);
this.resize.addResizeListener(this.parentRef, this.onComponentResize);
}
public componentDidUpdate() {
// Post render operations (previews on elements, etc.)
this.props.templateService.initPreviewElements();
this.onComponentResize();
} }
public componentWillReceiveProps(nextProps: ISearchResultsTemplateProps) { public componentWillReceiveProps(nextProps: ISearchResultsTemplateProps) {
@ -37,4 +59,14 @@ export default class SearchResultsTemplate extends React.Component<ISearchResult
processedTemplate: template processedTemplate: template
}); });
} }
private onComponentResize() {
// Resize iframes accordingly
const nodes = document.querySelectorAll(".iframePreview, .video-js");
DomHelper.forEach(nodes, (index, elt) => {
elt.style.width = Math.floor(this.parentRef.offsetWidth/2) + 'px';
});
}
} }

View File

@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import IPagingProps from './IPagingProps'; import IPagingProps from './IPagingProps';
import Pagination from 'react-js-pagination'; import Pagination from 'react-js-pagination';
import styles from '../SearchResultsWebPart.module.scss';
export default class Paging extends React.Component<IPagingProps, null> { export default class Paging extends React.Component<IPagingProps, null> {
@ -13,15 +14,15 @@ export default class Paging extends React.Component<IPagingProps, null> {
public render(): React.ReactElement<IPagingProps> { public render(): React.ReactElement<IPagingProps> {
return( return(
<div className='searchWp__paginationContainer'> <div className={styles.searchWp__paginationContainer}>
<div className='searchWp__paginationContainer__pagination'> <div className={styles.searchWp__paginationContainer__pagination}>
<Pagination <Pagination
activePage={this.props.currentPage} activePage={this.props.currentPage}
firstPageText={<i className='ms-Icon ms-Icon--DoubleChevronLeft' aria-hidden='true'></i>} firstPageText={<i className='ms-Icon ms-Icon--DoubleChevronLeft' aria-hidden='true'></i>}
lastPageText={<i className='ms-Icon ms-Icon--DoubleChevronRight' aria-hidden='true'></i>} lastPageText={<i className='ms-Icon ms-Icon--DoubleChevronRight' aria-hidden='true'></i>}
prevPageText={<i className='ms-Icon ms-Icon--ChevronLeft' aria-hidden='true'></i>} prevPageText={<i className='ms-Icon ms-Icon--ChevronLeft' aria-hidden='true'></i>}
nextPageText={<i className='ms-Icon ms-Icon--ChevronRight' aria-hidden='true'></i>} nextPageText={<i className='ms-Icon ms-Icon--ChevronRight' aria-hidden='true'></i>}
activeLinkClass={ 'active' } activeLinkClass={ styles.active }
itemsCountPerPage={ this.props.itemsCountPerPage } itemsCountPerPage={ this.props.itemsCountPerPage }
totalItemsCount={ this.props.totalItems } totalItemsCount={ this.props.totalItems }
pageRangeDisplayed={5} pageRangeDisplayed={5}

View File

@ -14,12 +14,12 @@ interface ISearchResultsContainerProps {
/** /**
* The search data provider instance * The search data provider instance
*/ */
searchDataProvider: ISearchService; searchService: ISearchService;
/** /**
* The taxonomy data provider instance * The taxonomy data provider instance
*/ */
taxonomyDataProvider: ITaxonomyService; taxonomyService: ITaxonomyService;
/** /**
* The search query keywords * The search query keywords

View File

@ -42,11 +42,6 @@ interface ISearchResultsContainerState {
*/ */
areResultsLoading: boolean; areResultsLoading: boolean;
/**
* Indicates whether or not the componetn loads for the first time
*/
isComponentLoading: boolean;
/** /**
* Keeps the last query in case you change the query in the propery panel * Keeps the last query in case you change the query in the propery panel
*/ */

View File

@ -3,16 +3,16 @@ import ISearchContainerProps from './ISearchResultsContainerProps';
import ISearchContainerState from './ISearchResultsContainerState'; import ISearchContainerState from './ISearchResultsContainerState';
import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar'; import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { Shimmer, ShimmerElementType as ElemType } from 'office-ui-fabric-react/lib/Shimmer'; import { Shimmer, ShimmerElementType as ElemType, ShimmerElementsGroup } from 'office-ui-fabric-react/lib/Shimmer';
import { Logger, LogLevel } from '@pnp/logging'; import { Logger, LogLevel } from '@pnp/logging';
import * as strings from 'SearchWebPartStrings'; import * as strings from 'SearchWebPartStrings';
import { IRefinementFilter, IRefinementValue, IRefinementResult } from '../../../../models/ISearchResult'; import { IRefinementFilter, IRefinementValue, IRefinementResult } from '../../../../models/ISearchResult';
import '../SearchResultsWebPart.scss';
import Paging from '../Paging/Paging'; import Paging from '../Paging/Paging';
import { Overlay } from 'office-ui-fabric-react/lib/Overlay'; import { Overlay } from 'office-ui-fabric-react/lib/Overlay';
import { DisplayMode } from '@microsoft/sp-core-library'; import { DisplayMode } from '@microsoft/sp-core-library';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle"; import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import SearchResultsTemplate from '../Layouts/SearchResultsTemplate'; import SearchResultsTemplate from '../Layouts/SearchResultsTemplate';
import styles from '../SearchResultsWebPart.module.scss';
declare var System: any; declare var System: any;
let FilterPanel = null; let FilterPanel = null;
@ -33,7 +33,6 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
availableFilters: [], availableFilters: [],
currentPage: 1, currentPage: 1,
areResultsLoading: false, areResultsLoading: false,
isComponentLoading: true,
errorMessage: '', errorMessage: '',
hasError: false, hasError: false,
lastQuery: '' lastQuery: ''
@ -49,58 +48,75 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
const items = this.state.results; const items = this.state.results;
const hasError = this.state.hasError; const hasError = this.state.hasError;
const errorMessage = this.state.errorMessage; const errorMessage = this.state.errorMessage;
const isComponentLoading = this.state.isComponentLoading;
let renderWpContent: JSX.Element = null; let renderWpContent: JSX.Element = null;
let renderOverlay: JSX.Element = null; let renderOverlay: JSX.Element = null;
let renderWebPartTitle: JSX.Element = null;
if (!isComponentLoading && areResultsLoading) { if (areResultsLoading) {
if (items.RelevantResults.length > 0) {
renderOverlay = <div> renderOverlay = <div>
<Overlay isDarkThemed={false} className='overlay'> <Overlay isDarkThemed={false} className={styles.overlay}>
<Spinner size={SpinnerSize.medium} /> <Spinner size={SpinnerSize.medium} />
</Overlay> </Overlay>
</div>; </div>;
}
let webPartTitle = null;
if (this.props.webPartTitle && this.props.webPartTitle.length > 0) {
webPartTitle = <WebPartTitle title={this.props.webPartTitle} updateProperty={null} displayMode={DisplayMode.Read} />;
}
if (isComponentLoading) {
//renderWpContent = <Spinner size={SpinnerSize.large} label={strings.LoadingMessage} />;
renderWpContent = (<div>
<Shimmer isDataLoaded={!isComponentLoading} width={'75%'} style={{ marginBottom: "10px" }} />
<Shimmer isDataLoaded={!isComponentLoading} width={'90%'} style={{ marginBottom: "10px" }} />
<Shimmer isDataLoaded={!isComponentLoading} width={'50%'} />
</div>);
} else { } else {
let i = 0;
let renderShimmerElements: JSX.Element[] = [];
while (i < 4) {
renderShimmerElements.push(
<Shimmer
customElementsGroup={this._getShimmerElements()}
width="100%"
style={{ marginBottom: "20px" }}
/>);
i++;
}
renderWpContent = <div>{ renderShimmerElements }</div>;
}
}
if (this.props.webPartTitle && this.props.webPartTitle.length > 0) {
renderWebPartTitle = <WebPartTitle title={this.props.webPartTitle} updateProperty={null} displayMode={DisplayMode.Read} />;
}
if (hasError) { if (hasError) {
renderWpContent = <MessageBar messageBarType={MessageBarType.error}>{errorMessage}</MessageBar>; renderWpContent = <MessageBar messageBarType={MessageBarType.error}>{errorMessage}</MessageBar>;
} else { } else {
let filterPanel = this.state.availableFilters && this.state.availableFilters.length > 0 ? <FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={this.props.refiners} /> : <span />; const currentQuery = this.props.queryKeywords + this.props.searchService.queryTemplate + this.props.selectedProperties.join(',');
const renderFilterPanel = this.state.availableFilters && this.state.availableFilters.length > 0 ?
<FilterPanel
availableFilters={this.state.availableFilters}
onUpdateFilters={this._onUpdateFilters}
refinersConfiguration={this.props.refiners}
resetSelectedFilters={ this.state.lastQuery !== currentQuery ? true : false}
/> : <span />;
if (items.RelevantResults.length === 0) { if (items.RelevantResults.length === 0) {
if (!this.props.showBlank) { // Check if a search request has already been entered (to distinguish the first use scenario)
if (!this.props.showBlank && this.state.lastQuery && !this.state.areResultsLoading) {
renderWpContent = renderWpContent =
<div> <div>
{webPartTitle} {renderWebPartTitle}
{filterPanel} {renderFilterPanel}
<div className='searchWp__noresult'>{strings.NoResultMessage}</div> <div className={styles.searchWp__noresult}>{strings.NoResultMessage}</div>
</div>; </div>;
} else { } else {
if (this.props.displayMode === DisplayMode.Edit) { if (this.props.displayMode === DisplayMode.Edit && !areResultsLoading) {
renderWpContent = <MessageBar messageBarType={MessageBarType.info}>{strings.ShowBlankEditInfoMessage}</MessageBar>; renderWpContent = <MessageBar messageBarType={MessageBarType.info}>{strings.ShowBlankEditInfoMessage}</MessageBar>;
} }
} }
} else { } else {
renderWpContent = renderWpContent =
<div> <div>
{webPartTitle} {renderWebPartTitle}
{filterPanel} {renderFilterPanel}
{renderOverlay} {renderOverlay}
<SearchResultsTemplate <SearchResultsTemplate
templateService={this.props.templateService} templateService={this.props.templateService}
@ -108,13 +124,15 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
templateContext={ templateContext={
{ {
items: this.state.results.RelevantResults, items: this.state.results.RelevantResults,
promotedResults: this.state.results.PromotedResults,
totalRows: this.state.resultCount, totalRows: this.state.resultCount,
keywords: this.props.queryKeywords, keywords: this.props.queryKeywords,
showResultsCount: this.props.showResultsCount, showResultsCount: this.props.showResultsCount,
siteUrl: this.props.context.pageContext.site.serverRelativeUrl, siteUrl: this.props.context.pageContext.site.serverRelativeUrl,
webUrl: this.props.context.pageContext.web.serverRelativeUrl, webUrl: this.props.context.pageContext.web.serverRelativeUrl,
maxResultsCount: this.props.maxResultsCount, maxResultsCount: this.props.maxResultsCount,
actualResultsCount: items.RelevantResults.length actualResultsCount: items.RelevantResults.length,
strings: strings,
} }
} }
/> />
@ -129,10 +147,9 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
</div>; </div>;
} }
} }
}
return ( return (
<div className='searchWp'> <div className={styles.searchWp}>
{renderWpContent} {renderWpContent}
</div> </div>
); );
@ -148,11 +165,11 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
areResultsLoading: true, areResultsLoading: true,
}); });
this.props.searchDataProvider.selectedProperties = this.props.selectedProperties; this.props.searchService.selectedProperties = this.props.selectedProperties;
const refinerManagedProperties = Object.keys(this.props.refiners).join(','); const refinerManagedProperties = Object.keys(this.props.refiners).join(',');
const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, refinerManagedProperties, this.state.selectedFilters, this.state.currentPage); const searchResults = await this.props.searchService.search(this.props.queryKeywords, refinerManagedProperties, this.state.selectedFilters, this.state.currentPage);
const localizedFilters = await this._getLocalizedFilters(searchResults.RefinementResults); const localizedFilters = await this._getLocalizedFilters(searchResults.RefinementResults);
if (localizedFilters && localizedFilters.length > 0) { if (localizedFilters && localizedFilters.length > 0) {
@ -163,16 +180,12 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
FilterPanel = filterPanelComponent.FilterPanel; FilterPanel = filterPanelComponent.FilterPanel;
} }
// Initial filters are just set once for the filter control during the component initialization
// By this way, we are be able to select multiple values whithin a specific filter (OR condition). Otherwise, if we pass every time the new filters retrieved from new results,
// previous values will overwritten preventing to select multiple values (default SharePoint behavior)
this.setState({ this.setState({
results: searchResults, results: searchResults,
resultCount: searchResults.TotalRows, resultCount: searchResults.TotalRows,
availableFilters: localizedFilters, availableFilters: localizedFilters,
areResultsLoading: false, areResultsLoading: false,
isComponentLoading: false, lastQuery: this.props.queryKeywords + this.props.searchService.queryTemplate + this.props.selectedProperties.join(',')
lastQuery: this.props.queryKeywords + this.props.searchDataProvider.queryTemplate + this.props.selectedProperties.join(',')
}); });
} catch (error) { } catch (error) {
@ -181,7 +194,6 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
this.setState({ this.setState({
areResultsLoading: false, areResultsLoading: false,
isComponentLoading: false,
results: { RefinementResults: [], RelevantResults: [] }, results: { RefinementResults: [], RelevantResults: [] },
hasError: true, hasError: true,
errorMessage: error.message errorMessage: error.message
@ -189,15 +201,14 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
} }
} else { } else {
this.setState({ this.setState({
areResultsLoading: false, areResultsLoading: false
isComponentLoading: false,
}); });
} }
} }
public async componentWillReceiveProps(nextProps: ISearchContainerProps) { public async componentWillReceiveProps(nextProps: ISearchContainerProps) {
let query = nextProps.queryKeywords + nextProps.searchDataProvider.queryTemplate + nextProps.selectedProperties.join(','); let query = nextProps.queryKeywords + nextProps.searchService.queryTemplate + nextProps.selectedProperties.join(',');
// New props are passed to the component when the search query has been changed // New props are passed to the component when the search query has been changed
if (JSON.stringify(this.props.refiners) !== JSON.stringify(nextProps.refiners) if (JSON.stringify(this.props.refiners) !== JSON.stringify(nextProps.refiners)
@ -216,15 +227,15 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
areResultsLoading: true, areResultsLoading: true,
}); });
this.props.searchDataProvider.selectedProperties = nextProps.selectedProperties; this.props.searchService.selectedProperties = nextProps.selectedProperties;
const refinerManagedProperties = Object.keys(nextProps.refiners).join(','); const refinerManagedProperties = Object.keys(nextProps.refiners).join(',');
// We reset the page number and refinement filters // We reset the page number and refinement filters
const searchResults = await this.props.searchDataProvider.search(nextProps.queryKeywords, refinerManagedProperties, [], 1); const searchResults = await this.props.searchService.search(nextProps.queryKeywords, refinerManagedProperties, [], 1);
const localizedFilters = await this._getLocalizedFilters(searchResults.RefinementResults); const localizedFilters = await this._getLocalizedFilters(searchResults.RefinementResults);
if (FilterPanel == null && localizedFilters && localizedFilters.length > 0) { if (FilterPanel === null && localizedFilters && localizedFilters.length > 0) {
const filterPanelComponent = await System.import( const filterPanelComponent = await System.import(
/* webpackChunkName: 'search-filterpanel' */ /* webpackChunkName: 'search-filterpanel' */
'../FilterPanel' '../FilterPanel'
@ -238,6 +249,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
resultCount: searchResults.TotalRows, resultCount: searchResults.TotalRows,
availableFilters: localizedFilters, availableFilters: localizedFilters,
areResultsLoading: false, areResultsLoading: false,
currentPage: 1,
lastQuery: query lastQuery: query
}); });
@ -247,7 +259,6 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
this.setState({ this.setState({
areResultsLoading: false, areResultsLoading: false,
isComponentLoading: false,
results: { RefinementResults: [], RelevantResults: [] }, results: { RefinementResults: [], RelevantResults: [] },
hasError: true, hasError: true,
errorMessage: error.message errorMessage: error.message
@ -255,8 +266,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
} }
} else { } else {
this.setState({ this.setState({
areResultsLoading: false, areResultsLoading: false
isComponentLoading: false,
}); });
} }
} else { } else {
@ -293,10 +303,14 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
const refinerManagedProperties = Object.keys(this.props.refiners).join(','); const refinerManagedProperties = Object.keys(this.props.refiners).join(',');
const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, refinerManagedProperties, newFilters, 1); const searchResults = await
this.props.searchService.search(this.props.queryKeywords, refinerManagedProperties, newFilters, 1);
const localizedFilters = await
this._getLocalizedFilters(searchResults.RefinementResults);
this.setState({ this.setState({
results: searchResults, results: searchResults,
availableFilters: localizedFilters,
areResultsLoading: false, areResultsLoading: false,
}); });
} }
@ -314,7 +328,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
const refinerManagedProperties = Object.keys(this.props.refiners).join(','); const refinerManagedProperties = Object.keys(this.props.refiners).join(',');
const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, refinerManagedProperties, this.state.selectedFilters, pageNumber); const searchResults = await this.props.searchService.search(this.props.queryKeywords, refinerManagedProperties, this.state.selectedFilters, pageNumber);
this.setState({ this.setState({
results: searchResults, results: searchResults,
@ -359,8 +373,8 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
if (termsToLocalize.length > 0) { if (termsToLocalize.length > 0) {
// Process all terms in a single JSOM call for performance purpose. In general JSOM is pretty slow so we try to limit the number of calls... // Process all terms in a single JSOM call for performance purpose. In general JSOM is pretty slow so we try to limit the number of calls...
await this.props.taxonomyDataProvider.initialize(); await this.props.taxonomyService.initialize();
const termValues = await this.props.taxonomyDataProvider.getTermsById(termsToLocalize.map((t) => { return t.termId; })); const termValues = await this.props.taxonomyService.getTermsById(termsToLocalize.map((t) => { return t.termId; }));
const termsEnumerator = termValues.getEnumerator(); const termsEnumerator = termValues.getEnumerator();
@ -432,4 +446,24 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
return updatedFilters; return updatedFilters;
} }
private _getShimmerElements(): JSX.Element {
return <div style={{ display: 'flex' }}>
<ShimmerElementsGroup
shimmerElements={[
{ type: ElemType.line, width: 40, height: 40 },
{ type: ElemType.gap, width: 10, height: 40 }
]}
/>
<ShimmerElementsGroup
flexWrap={true}
width="100%"
shimmerElements={[
{ type: ElemType.line, width: '100%', height: 10 },
{ type: ElemType.line, width: '75%', height: 10 },
{ type: ElemType.gap, width: '25%', height: 20 }
]}
/>
</div>;
}
} }

View File

@ -1,17 +1,17 @@
.searchWp { .searchWp {
min-height: 35px;
// Needed to avoid overlay overflow
position: relative;
&__noresult { &__noresult {
padding:10px; padding:10px;
text-align: center; text-align: center;
} }
iframe {
border: 0;
width: 100%;
}
&__filterResultBtn { &__filterResultBtn {
color: "[theme: themePrimary]"; color: "[theme: themePrimary, default: #005a9e]";
} }
&__selectedFilters { &__selectedFilters {
@ -34,7 +34,7 @@
} }
i:hover { i:hover {
color: "[theme: themePrimary]"; color: "[theme: themePrimary, default: #005a9e]";
cursor: pointer; cursor: pointer;
} }
} }
@ -43,7 +43,7 @@
text-align: center; text-align: center;
margin-top: 15px; margin-top: 15px;
.searchWp__paginationContainer__pagination { &__pagination {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
@ -58,40 +58,42 @@
a { a {
float: left; float: left;
color: "[theme: themePrimary]"; color: "[theme: themePrimary, default: #005a9e]";
padding: 5px 10px; padding: 5px 10px;
text-decoration: none; text-decoration: none;
border-radius: 15px; border-radius: 15px;
i { i {
font-size: 10px; font-size: 10px;
color: "[theme: themePrimary]"; color: "[theme: themePrimary, default: #005a9e]";
} }
} }
.active {
background-color: "[theme: themePrimary, default: #005a9e]";
color: white;
}
a:visited { a:visited {
color: inherit; color: inherit;
} }
a.active {
background-color: "[theme: themePrimary]";
color: white;
}
}
} }
} }
} }
} }
.filterPanel { &__filterPanel {
position: relative;
&__body { &__body {
padding: 20px; padding-right: 20px;
padding-left: 20px;
overflow: auto; overflow: auto;
.header-icon { &__headerIcon {
text-align: right; text-align: right;
margin-bottom:8px; margin-top: 5px;
.ms-Icon { .ms-Icon {
font-size: 16px; font-size: 16px;
@ -106,12 +108,13 @@
} }
&__allFiltersToggle { &__allFiltersToggle {
margin-bottom: 25px; margin-bottom: 15px;
padding-left: 10px;
} }
&__group { &__group {
.ms-List-page~.ms-List-page { &__header {
margin-top: 15px; margin-top: 15px;
} }
@ -121,6 +124,7 @@
} }
} }
} }
}
.overlay { .overlay {
z-index: 1; z-index: 1;

View File

@ -10,7 +10,7 @@ define([], function() {
"NoResultMessage": "There are no results to show", "NoResultMessage": "There are no results to show",
"RefinersFieldLabel": "Refiners", "RefinersFieldLabel": "Refiners",
"FilterPanelTitle": "Available filters", "FilterPanelTitle": "Available filters",
"FilterResultsButtonLabel": "Filter results", "FilterResultsButtonLabel": "Filters",
"SelectedFiltersLabel": "Selected filters:", "SelectedFiltersLabel": "Selected filters:",
"ApplyAllFiltersLabel": "Apply all filters", "ApplyAllFiltersLabel": "Apply all filters",
"RemoveAllFiltersLabel": "Remove all filters", "RemoveAllFiltersLabel": "Remove all filters",
@ -50,6 +50,9 @@ define([], function() {
"ErrorTemplateExtension": "The template must be a valid .htm or .html file", "ErrorTemplateExtension": "The template must be a valid .htm or .html file",
"ErrorTemplateResolve": "Unable to resolve the specified template. Error details: '{0}'", "ErrorTemplateResolve": "Unable to resolve the specified template. Error details: '{0}'",
"WebPartTitle": "Web part title", "WebPartTitle": "Web part title",
"HandlebarsHelpersDescription": "Enable functions from moment and handlebars helpers. See https://github.com/SharePoint/sp-dev-fx-webparts/blob/master/samples/react-search-refiners/README.md#available-tokens for more information." "HandlebarsHelpersDescription": "Enable functions from moment and handlebars helpers. See https://github.com/SharePoint/sp-dev-fx-webparts/blob/master/samples/react-search-refiners/README.md#available-tokens for more information.",
"DynamicDataSourceLabel": "Available data sources",
"DynamicDataSourcePropertyLabel": "Available properties",
"PromotedResultsLabel": "Promoted result(s)"
} }
}); });

View File

@ -10,7 +10,7 @@ define([], function() {
"NoResultMessage": "Il n'y a aucun résultat à afficher.", "NoResultMessage": "Il n'y a aucun résultat à afficher.",
"RefinersFieldLabel": "Filtres", "RefinersFieldLabel": "Filtres",
"FilterPanelTitle": "Filtres disponibles", "FilterPanelTitle": "Filtres disponibles",
"FilterResultsButtonLabel": "Filtrer l'affichage", "FilterResultsButtonLabel": "Filtrer",
"SelectedFiltersLabel": "Filtre(s) appliqué(s):", "SelectedFiltersLabel": "Filtre(s) appliqué(s):",
"ApplyAllFiltersLabel": "Appliquer tous les filters", "ApplyAllFiltersLabel": "Appliquer tous les filters",
"RemoveAllFiltersLabel": "Supprimer tous les filtres", "RemoveAllFiltersLabel": "Supprimer tous les filtres",
@ -50,6 +50,9 @@ define([], function() {
"ErrorTemplateExtension": "Le file modèle doit être un fichier .htm ou .html valide", "ErrorTemplateExtension": "Le file modèle doit être un fichier .htm ou .html valide",
"ErrorTemplateResolve": "Impossible de résoudre le fichier. Détails: '{0}'", "ErrorTemplateResolve": "Impossible de résoudre le fichier. Détails: '{0}'",
"WebPartTitle": "Titre de web part", "WebPartTitle": "Titre de web part",
"HandlebarsHelpersDescription": "Activer les fonctions de moment et handlebars helpers. Voir https://github.com/SharePoint/sp-dev-fx-webparts/blob/master/samples/react-search-refiners/README.md#available-tokens pour plus d'informations." "HandlebarsHelpersDescription": "Activer les fonctions de moment et handlebars helpers. Voir https://github.com/SharePoint/sp-dev-fx-webparts/blob/master/samples/react-search-refiners/README.md#available-tokens pour plus d'informations.",
"DynamicDataSourceLabel": "Source de données disponibles",
"DynamicDataSourcePropertyLabel": "Propriétés disponibles",
"PromotedResultsLabel": "Résultat(s) promu(s)"
} }
}); });

View File

@ -50,6 +50,9 @@ declare interface ISearchWebPartStrings {
ErrorTemplateResolve: string; ErrorTemplateResolve: string;
WebPartTitle: string; WebPartTitle: string;
HandlebarsHelpersDescription: string; HandlebarsHelpersDescription: string;
DynamicDataSourceLabel: string;
DynamicDataSourcePropertyLabel: string;
PromotedResultsLabel: string;
} }
declare module 'SearchWebPartStrings' { declare module 'SearchWebPartStrings' {