[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.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.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
**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"/>
</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 ####
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 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 [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" />

View File

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

View File

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

View File

@ -12,7 +12,7 @@
"pageUrl": "https://localhost:5432/workbench"
},
"queryStringDataSource": {
"pageUrl": "https://collaborationcorner.sharepoint.com/teams/PnPIntranet/SitePages/Search(1).aspx",
"pageUrl": "https://collaborationcorner.sharepoint.com/teams/PnPIntranet/_layouts/15/workbench.aspx",
"customActions": {
"24cae67d-dec7-4eff-bb41-49451d5b5a11": {
"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",
"version": "2.0.0",
"version": "2.1.0",
"private": true,
"engines": {
"node": ">=0.10.0"
@ -33,17 +33,18 @@
"@types/sharepoint": "2013.1.9",
"@types/webpack-env": "1.13.1",
"common-tags": "^1.8.0",
"downshift": "^1.31.14",
"downshift": "1.31.14",
"handlebars": "^4.0.12",
"handlebars-helpers": "^0.8.4",
"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-ace": "^6.1.4",
"react-ace": "6.1.4",
"react-custom-scrollbars": "4.1.2",
"react-dom": "15.6.2",
"react-js-pagination": "3.0.0"
"react-js-pagination": "3.0.0",
"video.js": "^7.1.0"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.6.0-plusbeta",

View File

@ -43,6 +43,10 @@ export default class QueryStringDataSourceApplicationCustomizer
this.context.dynamicDataSourceManager.initializeSource(this);
if (this._searchQuery) {
this.context.dynamicDataSourceManager.notifySourceChanged();
}
return Promise.resolve();
}
@ -70,24 +74,34 @@ export default class QueryStringDataSourceApplicationCustomizer
*/
private _bindPushState() {
const _pushState = () => {
const _pushState = (() => {
const _defaultPushState = history.pushState;
const _self = this;
return function (data: any, title: string, url?: string | null) {
const queryStringKeywords = UrlHelper.getQueryStringParam("q", url);
if (queryStringKeywords && queryStringKeywords !== _self._searchQuery) {
_self._searchQuery = queryStringKeywords;
_self.context.dynamicDataSourceManager.notifyPropertyChanged('queryStringQuery');
}
_self._updateQuery(_self, url);
// Call the original function with the provided arguments
// This context is necessary for the context of the history change
return _defaultPushState.apply(this, [data, title, url]);
};
};
}).bind(this);
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 {
RelevantResults: ISearchResult[];
RefinementResults: IRefinementResult[];
PromotedResults?: IPromotedResult[];
TotalRows?: number;
}
@ -14,6 +15,12 @@ export interface IRefinementResult {
Values: IRefinementValue[];
}
export interface IPromotedResult {
Url: string;
Title: string;
Description: string;
}
export interface IRefinementValue {
RefinementCount: number;
RefinementName: string;

View File

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

View File

@ -1,15 +1,15 @@
import * as Handlebars from 'handlebars';
import ISearchService from './ISearchService';
import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter } from '../../models/ISearchResult';
import { sp, SearchQuery, SearchResults, SPRest, Web, Sort, SortDirection, SearchSuggestQuery } from '@pnp/sp';
import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter, IPromotedResult } from '../../models/ISearchResult';
import { sp, SearchQuery, SearchResults, SPRest, Sort, SortDirection, SearchSuggestQuery } from '@pnp/sp';
import { Logger, LogLevel, ConsoleListener } from '@pnp/logging';
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { Text } from '@microsoft/sp-core-library';
import sortBy from 'lodash-es/sortBy';
import groupBy from 'lodash-es/groupBy';
import mapValues from 'lodash-es/mapValues';
import mapKeys from 'lodash-es/mapKeys';
import {sortBy, groupBy} from '@microsoft/sp-lodash-subset';
const mapKeys: any = require('lodash/mapKeys');
const mapValues: any = require('lodash/mapValues');
import LocalizationHelper from '../../helpers/LocalizationHelper';
declare var System: any;
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
const relevantResults: ISearchResult[] = await Promise.all(allItemsPromises);
@ -283,18 +307,17 @@ class SearchService implements ISearchService {
private async _mapToIcon(filename: string): Promise<string> {
const webAbsoluteUrl = this._context.pageContext.web.absoluteUrl;
const web = new Web(webAbsoluteUrl);
try {
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;
return iconUrl;
} catch (error) {
Logger.write('[SharePointDataProvider._mapToIcon()]: Error: ' + error, LogLevel.Error);
throw error;
Logger.write('[SearchService._mapToIcon()]: Error: ' + error, LogLevel.Error);
throw new Error(error);
}
}
@ -327,12 +350,13 @@ class SearchService implements ISearchService {
let refinementQueryConditions: string[] = [];
let refinementQueryString: string = null;
// Conditions between values inside a refiner property
const refinementFilters = mapValues(groupBy(selectedFilters, 'FilterName'), (values) => {
const refinementFilter = values.map((filter) => {
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) => {
@ -357,6 +381,7 @@ class SearchService implements ISearchService {
// Multiple filters
case (conditionsCount > 1): {
// Conditions between refiner properties
refinementQueryString = Text.format('and({0})', refinementQueryConditions.toString());
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 * as strings from 'SearchWebPartStrings';
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;
abstract class BaseTemplateService {
@ -35,21 +39,134 @@ abstract class BaseTemplateService {
*/
public static getListDefaultTemplate(): string {
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">
{{#if showResultsCount}}
<div class="template_resultCount">
<label class="ms-fontWeight-semibold">{{getCountMessage totalRows keywords}}</label>
</div>
{{/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">
{{#each items as |item|}}
<li class="ms-ListItem ms-ListItem--image" tabindex="0">
<div class="ms-ListItem-image template_icon" style="background-image:url('{{iconSrc}}')">
</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 class="template_listItem" tabindex="0">
<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>
</li>
{{/each}}
</ul>
@ -207,9 +324,165 @@ abstract class BaseTemplateService {
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 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;

View File

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

View File

@ -20,15 +20,16 @@ import { Log, Text, Environment, EnvironmentType } from
import ISearchService from '../../services/SearchService/ISearchService';
import MockSearchService from '../../services/SearchService/MockSearchService';
import SearchService from '../../services/SearchService/SearchService';
const LOG_SOURCE: string = '[SearchBoxWebPart_{0}]';
import DynamicDataHelper from '../../helpers/DynamicDataHelper';
export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWebPartProps> implements IDynamicDataCallables {
private readonly LOG_SOURCE: string = '[SearchBoxWebPart_{0}]';
private _searchService: ISearchService;
private _searchQuery: string;
private _source: IDynamicDataSource;
private _domElement: HTMLElement;
private _dynamicDataHelper: DynamicDataHelper;
/**
* Used to be able to unregister dynamic data events if the source is updated
@ -50,44 +51,37 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
}
/**
* Resolves the connected data sources
* 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
* Binds data source properties to the Web Part properties. In some cases, the data source configuration is not retrieved propertly due to updated ids
*/
private _initDynamicDataSource() {
private _bindDataSources() {
if (this.properties.dynamicDataSourceId
&& this.properties.dynamicDataSourcePropertyId
&& this.properties.dynamicDataSourceComponentId) {
const sourceFound = this._source ? true : false;
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
let sourceId = undefined;
if (this._source) {
sourceId = this._source.id;
} else {
// Try to resolve the source and get its id by the name
this._source = this._tryGetSourceByComponentId(this.properties.dynamicDataSourceComponentId);
sourceId = this._source ? this._source.id : undefined;
}
if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId && !sourceFound) {
if (sourceId) {
this.context.dynamicDataProvider.registerPropertyChanged(sourceId, this.properties.dynamicDataSourcePropertyId, this._dataSourceUpdated);
this._searchQuery = this._source.getPropertyValue(this.properties.dynamicDataSourcePropertyId);
let sourceId = undefined;
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId);
if (this._source ) {
sourceId = this._source .id;
} else {
this._source = this._dynamicDataHelper._tryGetSourceByInstanceOrComponentId(this.properties.sourceInstance);
sourceId = this._source ? this._source.id : undefined;
}
// Update the property for the property pane
this.properties.dynamicDataSourceId = sourceId;
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
if (sourceId) {
this.context.dynamicDataProvider.registerPropertyChanged(sourceId, this.properties.sourceInstance.propertyId, this.render);
// 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 (this.renderedOnce) {
this.render();
}
}
// Update the property for the property pane
this.properties.sourceInstance.sourceId = sourceId;
this._lastSourceId = sourceId;
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 (this.renderedOnce) {
this.render();
}
}
}
}
@ -95,60 +89,64 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
* Determines the group fields for the search query options inside the property pane
*/
private _getSearchQueryFields(): IPropertyPaneField<any>[] {
// Sets up search query fields
let searchQueryConfigFields: IPropertyPaneField<any>[] = [
PropertyPaneCheckbox('useDynamicDataSource', {
checked: false,
text: strings.UseDynamicDataSourceLabel,
})
];
// Sets up search query fields
let searchQueryConfigFields: IPropertyPaneField<any>[] = [
PropertyPaneCheckbox('useDynamicDataSource', {
checked: false,
text: strings.UseDynamicDataSourceLabel,
})
];
if (this.properties.useDynamicDataSource) {
const sourceOptions: IPropertyPaneDropdownOption[] =
this.context.dynamicDataProvider.getAvailableSources().map(source => {
return {
key: source.id,
text: source.metadata.title,
componentId: source.metadata.componentId
};
}).filter((item) => {
if (item.key.localeCompare("PageContext") !== 0 && item.componentId !== this.componentId) {
return item;
}
});
const selectedSource: string = this.properties.dynamicDataSourceId;
if (this.properties.useDynamicDataSource) {
const sourceOptions: IPropertyPaneDropdownOption[] =
this.context.dynamicDataProvider.getAvailableSources().map(source => {
return {
key: source.id,
text: source.metadata.title,
instanceId: source.metadata.instanceId,
componentId: source.metadata.componentId
};
}).filter((item) => {
// 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;
}
});
let propertyOptions: IPropertyPaneDropdownOption[] = [];
if (selectedSource) {
const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource);
if (source) {
propertyOptions = source.getPropertyDefinitions().map(prop => {
return {
key: prop.id,
text: prop.title
};
});
}
}
const selectedSource: string = this.properties.sourceInstance.sourceId;
let propertyOptions: IPropertyPaneDropdownOption[] = [];
if (selectedSource) {
const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource);
if (source) {
propertyOptions = source.getPropertyDefinitions().map(prop => {
return {
key: prop.id,
text: prop.title
};
});
}
}
searchQueryConfigFields = searchQueryConfigFields.concat([
PropertyPaneDropdown('dynamicDataSourceId', {
label: "Source",
options: sourceOptions,
selectedKey: this.properties.dynamicDataSourceId,
}),
PropertyPaneDropdown('dynamicDataSourcePropertyId', {
disabled: !this.properties.dynamicDataSourceId,
label: "Source property",
options: propertyOptions,
selectedKey: this.properties.dynamicDataSourcePropertyId
}),
]);
}
searchQueryConfigFields = searchQueryConfigFields.concat([
PropertyPaneDropdown('sourceInstance.sourceId', {
label: strings.DynamicDataSourceLabel,
options: sourceOptions,
selectedKey: this.properties.sourceInstance.sourceId,
}),
PropertyPaneDropdown('sourceInstance.propertyId', {
disabled: !this.properties.sourceInstance.sourceId,
label: strings.DynamicDataSourcePropertyLabel,
options: propertyOptions,
selectedKey: this.properties.sourceInstance.propertyId
})
]);
}
return searchQueryConfigFields;
return searchQueryConfigFields;
}
/**
@ -188,21 +186,6 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
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
* @param value the URL to verify
@ -216,28 +199,6 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
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
*/
@ -253,50 +214,62 @@ 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> {
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.context.dynamicDataSourceManager.initializeSource(this);
// Make sure the data source will be plugged in correctly when loaded on the page
// 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));
// Re bind data sources to WebPart properties
this._reconnectDataSource();
return Promise.resolve();
}
protected onPropertyPaneFieldChanged(changedProperty: string) {
protected onPropertyPaneFieldChanged(propertyPath: string) {
this.initSearchService();
if (changedProperty === 'dynamicDataSourceId') {
if (propertyPath === 'sourceInstance.sourceId') {
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._dataSourceUpdated);
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
// Select the first property by default
this.properties.sourceInstance.propertyId =
this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId).getPropertyDefinitions()[0].id;
}
if (changedProperty === 'useDynamicDataSource') {
if (!this.properties.useDynamicDataSource) {
this.context.dynamicDataProvider.unregisterAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
}
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;
}
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);
}
}
}
@ -341,6 +314,22 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
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(
SearchBox, {
onSearch: this._onSearch,
@ -352,6 +341,6 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
searchService: this._searchService
} 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 { Label } from 'office-ui-fabric-react/lib/Label';
import * as update from 'immutability-helper';
import '../SearchBoxWebPart.scss';
import styles from '../SearchBoxWebPart.module.scss';
const SUGGESTION_CHAR_COUNT_TRIGGER = 3;
@ -125,9 +125,9 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
let suggestions: JSX.Element[] = null;
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 className="suggestionItem">
<div className={styles.suggestionItem}>
<Spinner size={ SpinnerSize.small }/>
</div>
</div>
@ -142,13 +142,13 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
style={{
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>
</Label>
</div>;
});
renderSuggestions = <div className="suggestionPanel">
renderSuggestions = <div className={styles.suggestionPanel}>
{ suggestions }
</div>;
}
@ -285,7 +285,7 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
errorMessage: null,
});
}}
className="errorMessage">
className={styles.errorMessage}>
{ this.state.errorMessage }</MessageBar>;
}
@ -293,7 +293,7 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
this.renderSearchBoxWithAutoComplete() :
this.renderBasicSearchBox();
return (
<div className="searchBox">
<div className={styles.searchBox}>
{ renderErrorMessage }
{ renderSearchBox }
</div>

View File

@ -12,6 +12,8 @@ define([], function() {
"SearchBoxPageOpenBehaviorLabel": "Opening behavior",
"SearchBoxDynamicPropertyInputLabel": "Input value",
"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",
"SearchBoxDynamicPropertyInputLabel": "Valeur du champ 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;
UseDynamicDataSourceLabel: string;
SearchBoxDynamicDataSourceGroupName: string;
DynamicDataSourceLabel: string;
DynamicDataSourcePropertyLabel: string;
}
declare module 'SearchBoxWebPartStrings' {

View File

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

View File

@ -25,7 +25,7 @@
"queryKeywords": "",
"queryTemplate": "{searchTerms} Path:{Site}",
"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,
"maxResultsCount": 10,
"showBlank": true,

View File

@ -37,21 +37,22 @@ import { update, isEmpty } from '@microsoft/sp-lodash-subset';
import MockTemplateService from '../../services/TemplateService/MockTemplateService';
import BaseTemplateService from '../../services/TemplateService/BaseTemplateService';
import { IDynamicDataSource } from '@microsoft/sp-dynamic-data';
import DynamicDataHelper from '../../helpers/DynamicDataHelper';
declare var System: any;
const LOG_SOURCE: string = '[SearchResultsWebPart_{0}]';
export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchResultsWebPartProps> {
private readonly LOG_SOURCE: string = '[SearchResultsWebPart_{0}]';
private _searchService: ISearchService;
private _taxonomyService: ITaxonomyService;
private _templateService: BaseTemplateService;
private _useResultSource: boolean;
private _queryKeywords: string;
private _source: IDynamicDataSource;
private _domElement: HTMLElement;
private _propertyPage = null;
private _dynamicDataHelper: DynamicDataHelper;
/**
* 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
* 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
* Binds data source properties to the Web Part properties. In some cases, the data source configuration is not retrieved propertly due to updated ids
*/
private _initDynamicDataSource() {
private _bindDataSources() {
if (this.properties.dynamicDataSourceId
&& this.properties.dynamicDataSourcePropertyId
&& this.properties.dynamicDataSourceComponentId) {
const sourceFound = this._source ? true : false;
if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId && !sourceFound) {
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
let sourceId = undefined;
if (this._source) {
sourceId = this._source.id;
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.sourceInstance.sourceId);
if (this._source ) {
sourceId = this._source .id;
} else {
// Try to resolve the source and get its id by the name
this._source = this._tryGetSourceByComponentId(this.properties.dynamicDataSourceComponentId);
this._source = this._dynamicDataHelper._tryGetSourceByInstanceOrComponentId(this.properties.sourceInstance);
sourceId = this._source ? this._source.id : undefined;
}
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
this.properties.dynamicDataSourceId = sourceId;
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
this.properties.sourceInstance.sourceId = sourceId;
this._lastSourceId = sourceId;
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 (this.renderedOnce) {
@ -109,27 +107,16 @@ 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
* @param dataSourceComponentId the component id
* 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 _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;
private _reconnectDataSource() {
if (this.properties.sourceInstance.sourceId && this.properties.sourceInstance.propertyId) {
this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._bindDataSources.bind(this));
}
}
/**
* 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,
value: this.properties.refiners,
deferredValidationTime: 300,
})
}),
PropertyPaneSlider('maxResultsCount', {
label: strings.MaxResultsCount,
max: 50,
min: 1,
showValue: true,
step: 1,
value: 50,
}),
];
return searchSettingsFields;
@ -198,16 +193,25 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
if (this.properties.useSearchBoxQuery) {
const sourceOptions: IPropertyPaneDropdownOption[] =
this.context.dynamicDataProvider.getAvailableSources().map(source => {
return {
key: source.id,
text: source.metadata.title
};
}).filter(item => item.key.localeCompare("PageContext") !== 0);
const selectedSource: string = this.properties.dynamicDataSourceId;
this.context.dynamicDataProvider.getAvailableSources().map(source => {
return {
key: source.id,
text: source.metadata.title,
instanceId: source.metadata.instanceId,
componentId: source.metadata.componentId
};
}).filter((item) => {
// 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;
}
});
const selectedSource: string = this.properties.sourceInstance.sourceId;
let propertyOptions: IPropertyPaneDropdownOption[] = [];
if (selectedSource) {
const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource);
if (source) {
@ -221,17 +225,17 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
}
searchQueryConfigFields = searchQueryConfigFields.concat([
PropertyPaneDropdown('dynamicDataSourceId', {
label: "Source",
PropertyPaneDropdown('sourceInstance.sourceId', {
label: strings.DynamicDataSourceLabel,
options: sourceOptions,
selectedKey: this.properties.dynamicDataSourceId,
selectedKey: this.properties.sourceInstance.sourceId,
}),
PropertyPaneDropdown('dynamicDataSourcePropertyId', {
disabled: !this.properties.dynamicDataSourceId,
label: "Source property",
PropertyPaneDropdown('sourceInstance.propertyId', {
disabled: !this.properties.sourceInstance.sourceId,
label: strings.DynamicDataSourcePropertyLabel,
options: propertyOptions,
selectedKey: this.properties.dynamicDataSourcePropertyId
}),
selectedKey: this.properties.sourceInstance.propertyId
})
]);
} 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;
}
@ -514,7 +507,16 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
*/
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) {
this._searchService = new MockSearchService();
@ -537,9 +539,7 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
// Set the default search results layout
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
// 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));
this._reconnectDataSource();
return super.onInit();
}
@ -564,8 +564,8 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
const searchContainer: React.ReactElement<ISearchContainerProps> = React.createElement(
SearchContainer,
{
searchDataProvider: this._searchService,
taxonomyDataProvider: this._taxonomyService,
searchService: this._searchService,
taxonomyService: this._taxonomyService,
queryKeywords: this._queryKeywords,
maxResultsCount: this.properties.maxResultsCount,
resultSourceId: this.properties.resultSourceId,
@ -596,7 +596,7 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
);
if ((this.properties.queryKeywords && !this.properties.useSearchBoxQuery) ||
(this.properties.useSearchBoxQuery && this.properties.dynamicDataSourcePropertyId)) {
(this.properties.useSearchBoxQuery && this.properties.sourceInstance.sourceId)) {
renderElement = searchContainer;
} else {
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 {
@ -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
this.properties.dynamicDataSourceId = undefined;
this.properties.dynamicDataSourcePropertyId = undefined;
this.context.dynamicDataProvider.unregisterAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
this.properties.sourceInstance.sourceId = undefined;
this.properties.sourceInstance.propertyId = undefined;
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...
if (changedProperty === 'inlineTemplateText') {
if (propertyPath === 'inlineTemplateText') {
// Automatically switch the option to 'Custom' if a default template has been edited
// (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);
}
@ -716,8 +728,18 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
// If a source is selected, use the value from here
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 { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel';
import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox';
import { Toggle } from 'office-ui-fabric-react/lib/Toggle';
import * as strings from 'SearchWebPartStrings';
import { IRefinementValue, IRefinementFilter } from '../../../../models/ISearchResult';
import { Label } from 'office-ui-fabric-react/lib/Label';
import { Text } from '@microsoft/sp-core-library';
import '../SearchResultsWebPart.scss';
import * as update from 'immutability-helper';
import {
GroupedList,
@ -16,7 +14,8 @@ import {
IGroupDividerProps
} from 'office-ui-fabric-react/lib/components/GroupedList/index';
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> {
@ -34,7 +33,6 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
this._addFilter = this._addFilter.bind(this);
this._removeFilter = this._removeFilter.bind(this);
this._isInFilterSelection = this._isInFilterSelection.bind(this);
this._applyAllfilters = this._applyAllfilters.bind(this);
this._removeAllFilters = this._removeAllFilters.bind(this);
this._onRenderHeader = this._onRenderHeader.bind(this);
this._onRenderCell = this._onRenderCell.bind(this);
@ -61,7 +59,6 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
items.push(
<div key={i}>
<div className='filterPanel__filterProperty'>
{
filter.Values.map((refinementValue: IRefinementValue, j) => {
@ -84,7 +81,6 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
);
})
}
</div>
</div>
);
});
@ -92,7 +88,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
const renderSelectedFilters: JSX.Element[] = this.state.selectedFilters.map((filter) => {
return (
<Label className='filter'>
<Label className={styles.filter}>
<i className='ms-Icon ms-Icon--ClearFilter' onClick={() => { this._removeFilter(filter); }}></i>
{filter.Value.RefinementName}
</Label>
@ -103,7 +99,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
ref='groupedList'
items={items}
onRenderCell={this._onRenderCell}
className='filterPanel__body__group'
className={styles.searchWp__filterPanel__body__group}
groupProps={
{
onRenderHeader: this._onRenderHeader,
@ -111,64 +107,81 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
}
groups={groups} />;
const renderLinkRemoveAll = this.state.selectedFilters.length > 0 ?
<Link onClick={this._removeAllFilters}>
{strings.RemoveAllFiltersLabel}
</Link> : null;
return (
<div>
<ActionButton
className='searchWp__filterResultBtn'
iconProps={{ iconName: 'Filter' }}
text={strings.FilterResultsButtonLabel}
onClick={this._onTogglePanel}
/>
<div className="ms-textAlignRight">
<ActionButton
className={`${styles.searchWp__filterResultBtn} ms-fontWeight-semibold`}
iconProps={{ iconName: 'Filter' }}
text={strings.FilterResultsButtonLabel}
onClick={this._onTogglePanel}
/>
</div>
{(this.state.selectedFilters.length > 0) ?
<div className='searchWp__selectedFilters'>
<div className={styles.searchWp__selectedFilters}>
{renderSelectedFilters}
</div>
: null
}
<Panel
className='filterPanel'
isOpen={this.state.showPanel}
type={PanelType.smallFixedNear}
isBlocking={false}
isLightDismiss={true}
onDismiss={this._onClosePanel}
headerText={strings.FilterPanelTitle}
closeButtonAriaLabel='Close'
hasCloseButton={true}
headerClassName='filterPanel__header'
onRenderBody={() => {
if (this.props.availableFilters.length > 0) {
return (
<Scrollbars style={{ height: '100%' }}>
<div className='filterPanel__body'>
<div className='filterPanel__body__allFiltersToggle'>
<Toggle
onText={strings.RemoveAllFiltersLabel}
offText={strings.ApplyAllFiltersLabel}
onChanged={(checked: boolean) => {
checked ? this._applyAllfilters() : this._removeAllFilters();
}}
checked={this.state.selectedFilters.length === 0 ? false : true}
/>
className={styles.searchWp__filterPanel}
isOpen={this.state.showPanel}
type={PanelType.custom}
customWidth="450px"
isBlocking={false}
isLightDismiss={true}
onDismiss={this._onClosePanel}
headerText={strings.FilterPanelTitle}
closeButtonAriaLabel='Close'
hasCloseButton={true}
onRenderBody={() => {
if (this.props.availableFilters.length > 0) {
return (
<Scrollbars style={{height: '100%'}}>
<div className={styles.searchWp__filterPanel__body}>
<div
className={`${styles.searchWp__filterPanel__body__allFiltersToggle} ${this.state.selectedFilters.length == 0 && "hiddenLink"}`}>
{renderLinkRemoveAll}
</div>
{renderAvailableFilters}
</div>
{renderAvailableFilters}
</Scrollbars>
);
} else {
return (
<div className={styles.searchWp__filterPanel__body}>
{strings.NoFilterConfiguredLabel}
</div>
</Scrollbars>
);
} else {
return (
<div className='filterPanel__body'>
{strings.NoFilterConfiguredLabel}
</div>
);
}
}}>
</Panel>
);
}
}}>
</Panel>
</div>
);
}
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) {
return (
<div className='ms-Grid-row' data-selection-index={itemIndex}>
@ -181,28 +194,29 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
private _onRenderHeader(props: IGroupDividerProps): JSX.Element {
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
const updatedExpandedGroups =
props.group.isCollapsed ?
update(this.state.expandedGroups, { $push: [props.group.startIndex] }) :
update(this.state.expandedGroups, { $splice: [[this.state.expandedGroups.indexOf(props.group.startIndex), 1]] });
// Update the index for expanded groups to be able to keep it open after a re-render
const updatedExpandedGroups =
props.group.isCollapsed ?
update(this.state.expandedGroups, { $push: [props.group.startIndex] }) :
update(this.state.expandedGroups, { $splice: [[this.state.expandedGroups.indexOf(props.group.startIndex), 1]] });
this.setState({
expandedGroups: updatedExpandedGroups,
});
this.setState({
expandedGroups: updatedExpandedGroups,
});
props.onToggleCollapse(props.group);
}}>
<div className='ms-Grid-col ms-u-sm1 ms-u-md1 ms-u-lg1'>
<div className='header-icon'>
<i className={props.group.isCollapsed ? 'ms-Icon ms-Icon--ChevronDown' : 'ms-Icon ms-Icon--ChevronUp'}></i>
props.onToggleCollapse(props.group);
}}>
<div className='ms-Grid-col ms-u-sm1 ms-u-md1 ms-u-lg1'>
<div className={styles.searchWp__filterPanel__body__headerIcon}>
<i className={props.group.isCollapsed ? 'ms-Icon ms-Icon--ChevronDown' : 'ms-Icon ms-Icon--ChevronUp'}></i>
</div>
</div>
<div className='ms-Grid-col ms-u-sm10 ms-u-md10 ms-u-lg10'>
<div className='ms-font-l'>{props.group.name}</div>
</div>
</div>
<div className='ms-Grid-col ms-u-sm10 ms-u-md10 ms-u-lg10'>
<div className='ms-font-l'>{props.group.name}</div>
</div>
</div>
);
@ -219,7 +233,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
private _addFilter(filterToAdd: IRefinementFilter): void {
// Add the filter to the selected filters collection
let newFilters = update(this.state.selectedFilters, { $push: [filterToAdd] });
let newFilters = update(this.state.selectedFilters, {$push: [filterToAdd]});
this._applyFilters(newFilters);
}
@ -233,20 +247,6 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
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 {
this._applyFilters([]);
}

View File

@ -5,6 +5,7 @@ interface IFilterPanelProps {
availableFilters: IRefinementResult[];
refinersConfiguration: { [key: string]: string };
onUpdateFilters: RefinementFilterOperationCallback;
resetSelectedFilters: boolean;
}
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
*/
interface ISearchResultsTemplateContext {
items: ISearchResult[];
promotedResults?: IPromotedResult[];
strings: ISearchWebPartStrings;
totalRows: number;
keywords: string;
showResultsCount: boolean;

View File

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

View File

@ -1,19 +1,39 @@
.searchWp {
.template_root {
@import '~office-ui-fabric/dist/sass/Fabric.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/ListItem/ListItem.scss';
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/FabricCore.scss';
.template_defaultList {
.template_icon {
background-position: top;
background-repeat: no-repeat;
}
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 {
.singleCard {
margin: 10px;
@ -42,6 +62,7 @@
cursor: pointer;
}
}
.template_resultCount {
padding-left: 10px;
margin-bottom: 10px;

View File

@ -2,24 +2,46 @@ import React = require('react');
import ISearchResultsTemplateProps from './ISearchResultsTemplateProps';
import ISearchResultsTemplateState from './ISearchResultsTemplateState';
import './SearchResultsTemplate.scss';
import { Resize } from 'on-el-resize';
import { DomHelper } from '../../../../helpers/DomHelper';
export default class SearchResultsTemplate extends React.Component<ISearchResultsTemplateProps, ISearchResultsTemplateState> {
private parentRef: HTMLElement;
private resize: Resize;
constructor() {
super();
this.resize = new Resize();
this.state = {
processedTemplate: null
};
this.onComponentResize = this.onComponentResize.bind(this);
}
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() {
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) {
@ -37,4 +59,14 @@ export default class SearchResultsTemplate extends React.Component<ISearchResult
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 IPagingProps from './IPagingProps';
import Pagination from 'react-js-pagination';
import styles from '../SearchResultsWebPart.module.scss';
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> {
return(
<div className='searchWp__paginationContainer'>
<div className='searchWp__paginationContainer__pagination'>
<div className={styles.searchWp__paginationContainer}>
<div className={styles.searchWp__paginationContainer__pagination}>
<Pagination
activePage={this.props.currentPage}
firstPageText={<i className='ms-Icon ms-Icon--DoubleChevronLeft' 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>}
nextPageText={<i className='ms-Icon ms-Icon--ChevronRight' aria-hidden='true'></i>}
activeLinkClass={ 'active' }
activeLinkClass={ styles.active }
itemsCountPerPage={ this.props.itemsCountPerPage }
totalItemsCount={ this.props.totalItems }
pageRangeDisplayed={5}

View File

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

View File

@ -42,11 +42,6 @@ interface ISearchResultsContainerState {
*/
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
*/

View File

@ -3,16 +3,16 @@ import ISearchContainerProps from './ISearchResultsContainerProps';
import ISearchContainerState from './ISearchResultsContainerState';
import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
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 * as strings from 'SearchWebPartStrings';
import { IRefinementFilter, IRefinementValue, IRefinementResult } from '../../../../models/ISearchResult';
import '../SearchResultsWebPart.scss';
import Paging from '../Paging/Paging';
import { Overlay } from 'office-ui-fabric-react/lib/Overlay';
import { DisplayMode } from '@microsoft/sp-core-library';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import SearchResultsTemplate from '../Layouts/SearchResultsTemplate';
import styles from '../SearchResultsWebPart.module.scss';
declare var System: any;
let FilterPanel = null;
@ -33,7 +33,6 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
availableFilters: [],
currentPage: 1,
areResultsLoading: false,
isComponentLoading: true,
errorMessage: '',
hasError: false,
lastQuery: ''
@ -49,90 +48,108 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
const items = this.state.results;
const hasError = this.state.hasError;
const errorMessage = this.state.errorMessage;
const isComponentLoading = this.state.isComponentLoading;
let renderWpContent: JSX.Element = null;
let renderOverlay: JSX.Element = null;
let renderWebPartTitle: JSX.Element = null;
if (!isComponentLoading && areResultsLoading) {
renderOverlay = <div>
<Overlay isDarkThemed={false} className='overlay'>
<Spinner size={SpinnerSize.medium} />
</Overlay>
</div>;
}
if (areResultsLoading) {
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 {
if (hasError) {
renderWpContent = <MessageBar messageBarType={MessageBarType.error}>{errorMessage}</MessageBar>;
if (items.RelevantResults.length > 0) {
renderOverlay = <div>
<Overlay isDarkThemed={false} className={styles.overlay}>
<Spinner size={SpinnerSize.medium} />
</Overlay>
</div>;
} else {
let filterPanel = this.state.availableFilters && this.state.availableFilters.length > 0 ? <FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={this.props.refiners} /> : <span />;
if (items.RelevantResults.length === 0) {
if (!this.props.showBlank) {
renderWpContent =
<div>
{webPartTitle}
{filterPanel}
<div className='searchWp__noresult'>{strings.NoResultMessage}</div>
</div>;
} else {
if (this.props.displayMode === DisplayMode.Edit) {
renderWpContent = <MessageBar messageBarType={MessageBarType.info}>{strings.ShowBlankEditInfoMessage}</MessageBar>;
}
}
} else {
renderWpContent =
<div>
{webPartTitle}
{filterPanel}
{renderOverlay}
<SearchResultsTemplate
templateService={this.props.templateService}
templateContent={this.props.templateContent}
templateContext={
{
items: this.state.results.RelevantResults,
totalRows: this.state.resultCount,
keywords: this.props.queryKeywords,
showResultsCount: this.props.showResultsCount,
siteUrl: this.props.context.pageContext.site.serverRelativeUrl,
webUrl: this.props.context.pageContext.web.serverRelativeUrl,
maxResultsCount: this.props.maxResultsCount,
actualResultsCount: items.RelevantResults.length
}
}
/>
{this.props.showPaging ?
<Paging
totalItems={items.TotalRows}
itemsCountPerPage={this.props.maxResultsCount}
onPageUpdate={this._onPageUpdate}
currentPage={this.state.currentPage} />
: null
}
</div>;
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) {
renderWpContent = <MessageBar messageBarType={MessageBarType.error}>{errorMessage}</MessageBar>;
} else {
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) {
// 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 =
<div>
{renderWebPartTitle}
{renderFilterPanel}
<div className={styles.searchWp__noresult}>{strings.NoResultMessage}</div>
</div>;
} else {
if (this.props.displayMode === DisplayMode.Edit && !areResultsLoading) {
renderWpContent = <MessageBar messageBarType={MessageBarType.info}>{strings.ShowBlankEditInfoMessage}</MessageBar>;
}
}
} else {
renderWpContent =
<div>
{renderWebPartTitle}
{renderFilterPanel}
{renderOverlay}
<SearchResultsTemplate
templateService={this.props.templateService}
templateContent={this.props.templateContent}
templateContext={
{
items: this.state.results.RelevantResults,
promotedResults: this.state.results.PromotedResults,
totalRows: this.state.resultCount,
keywords: this.props.queryKeywords,
showResultsCount: this.props.showResultsCount,
siteUrl: this.props.context.pageContext.site.serverRelativeUrl,
webUrl: this.props.context.pageContext.web.serverRelativeUrl,
maxResultsCount: this.props.maxResultsCount,
actualResultsCount: items.RelevantResults.length,
strings: strings,
}
}
/>
{this.props.showPaging ?
<Paging
totalItems={items.TotalRows}
itemsCountPerPage={this.props.maxResultsCount}
onPageUpdate={this._onPageUpdate}
currentPage={this.state.currentPage} />
: null
}
</div>;
}
}
return (
<div className='searchWp'>
<div className={styles.searchWp}>
{renderWpContent}
</div>
);
@ -148,11 +165,11 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
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 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);
if (localizedFilters && localizedFilters.length > 0) {
@ -163,16 +180,12 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
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({
results: searchResults,
resultCount: searchResults.TotalRows,
availableFilters: localizedFilters,
areResultsLoading: false,
isComponentLoading: false,
lastQuery: this.props.queryKeywords + this.props.searchDataProvider.queryTemplate + this.props.selectedProperties.join(',')
lastQuery: this.props.queryKeywords + this.props.searchService.queryTemplate + this.props.selectedProperties.join(',')
});
} catch (error) {
@ -181,7 +194,6 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
this.setState({
areResultsLoading: false,
isComponentLoading: false,
results: { RefinementResults: [], RelevantResults: [] },
hasError: true,
errorMessage: error.message
@ -189,15 +201,14 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
}
} else {
this.setState({
areResultsLoading: false,
isComponentLoading: false,
areResultsLoading: false
});
}
}
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
if (JSON.stringify(this.props.refiners) !== JSON.stringify(nextProps.refiners)
@ -216,15 +227,15 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
areResultsLoading: true,
});
this.props.searchDataProvider.selectedProperties = nextProps.selectedProperties;
this.props.searchService.selectedProperties = nextProps.selectedProperties;
const refinerManagedProperties = Object.keys(nextProps.refiners).join(',');
// 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);
if (FilterPanel == null && localizedFilters && localizedFilters.length > 0) {
if (FilterPanel === null && localizedFilters && localizedFilters.length > 0) {
const filterPanelComponent = await System.import(
/* webpackChunkName: 'search-filterpanel' */
'../FilterPanel'
@ -238,6 +249,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
resultCount: searchResults.TotalRows,
availableFilters: localizedFilters,
areResultsLoading: false,
currentPage: 1,
lastQuery: query
});
@ -247,7 +259,6 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
this.setState({
areResultsLoading: false,
isComponentLoading: false,
results: { RefinementResults: [], RelevantResults: [] },
hasError: true,
errorMessage: error.message
@ -255,8 +266,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
}
} else {
this.setState({
areResultsLoading: false,
isComponentLoading: false,
areResultsLoading: false
});
}
} else {
@ -293,10 +303,14 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
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({
results: searchResults,
availableFilters: localizedFilters,
areResultsLoading: false,
});
}
@ -314,7 +328,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
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({
results: searchResults,
@ -359,8 +373,8 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
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...
await this.props.taxonomyDataProvider.initialize();
const termValues = await this.props.taxonomyDataProvider.getTermsById(termsToLocalize.map((t) => { return t.termId; }));
await this.props.taxonomyService.initialize();
const termValues = await this.props.taxonomyService.getTermsById(termsToLocalize.map((t) => { return t.termId; }));
const termsEnumerator = termValues.getEnumerator();
@ -432,4 +446,24 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
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 {
min-height: 35px;
// Needed to avoid overlay overflow
position: relative;
&__noresult {
padding:10px;
text-align: center;
}
iframe {
border: 0;
width: 100%;
}
&__filterResultBtn {
color: "[theme: themePrimary]";
color: "[theme: themePrimary, default: #005a9e]";
}
&__selectedFilters {
@ -34,7 +34,7 @@
}
i:hover {
color: "[theme: themePrimary]";
color: "[theme: themePrimary, default: #005a9e]";
cursor: pointer;
}
}
@ -43,7 +43,7 @@
text-align: center;
margin-top: 15px;
.searchWp__paginationContainer__pagination {
&__pagination {
display: inline-block;
text-align: center;
@ -58,65 +58,69 @@
a {
float: left;
color: "[theme: themePrimary]";
color: "[theme: themePrimary, default: #005a9e]";
padding: 5px 10px;
text-decoration: none;
border-radius: 15px;
i {
font-size: 10px;
color: "[theme: themePrimary]";
}
color: "[theme: themePrimary, default: #005a9e]";
}
}
.active {
background-color: "[theme: themePrimary, default: #005a9e]";
color: white;
}
a:visited {
color: inherit;
}
a.active {
background-color: "[theme: themePrimary]";
color: white;
}
}
}
}
}
}
.filterPanel {
&__filterPanel {
&__body {
padding: 20px;
overflow: auto;
position: relative;
.header-icon {
text-align: right;
margin-bottom:8px;
.ms-Icon {
font-size: 16px;
font-weight: 500;
line-height: 1.5;
letter-spacing: 0.5px;
&.ms-Icon--CalculatorSubtract, &.ms-Icon--CalculatorAddition {
font-weight: bold;
&__body {
padding-right: 20px;
padding-left: 20px;
overflow: auto;
&__headerIcon {
text-align: right;
margin-top: 5px;
.ms-Icon {
font-size: 16px;
font-weight: 500;
line-height: 1.5;
letter-spacing: 0.5px;
&.ms-Icon--CalculatorSubtract, &.ms-Icon--CalculatorAddition {
font-weight: bold;
}
}
}
}
&__allFiltersToggle {
margin-bottom: 25px;
}
&__group {
.ms-List-page~.ms-List-page {
margin-top: 15px;
&__allFiltersToggle {
margin-bottom: 15px;
padding-left: 10px;
}
button {
padding: 10px;
&__group {
&__header {
margin-top: 15px;
}
button {
padding: 10px;
}
}
}
}

View File

@ -10,7 +10,7 @@ define([], function() {
"NoResultMessage": "There are no results to show",
"RefinersFieldLabel": "Refiners",
"FilterPanelTitle": "Available filters",
"FilterResultsButtonLabel": "Filter results",
"FilterResultsButtonLabel": "Filters",
"SelectedFiltersLabel": "Selected filters:",
"ApplyAllFiltersLabel": "Apply all filters",
"RemoveAllFiltersLabel": "Remove all filters",
@ -50,6 +50,9 @@ define([], function() {
"ErrorTemplateExtension": "The template must be a valid .htm or .html file",
"ErrorTemplateResolve": "Unable to resolve the specified template. Error details: '{0}'",
"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.",
"RefinersFieldLabel": "Filtres",
"FilterPanelTitle": "Filtres disponibles",
"FilterResultsButtonLabel": "Filtrer l'affichage",
"FilterResultsButtonLabel": "Filtrer",
"SelectedFiltersLabel": "Filtre(s) appliqué(s):",
"ApplyAllFiltersLabel": "Appliquer tous les filters",
"RemoveAllFiltersLabel": "Supprimer tous les filtres",
@ -50,6 +50,9 @@ define([], function() {
"ErrorTemplateExtension": "Le file modèle doit être un fichier .htm ou .html valide",
"ErrorTemplateResolve": "Impossible de résoudre le fichier. Détails: '{0}'",
"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;
WebPartTitle: string;
HandlebarsHelpersDescription: string;
DynamicDataSourceLabel: string;
DynamicDataSourcePropertyLabel: string;
PromotedResultsLabel: string;
}
declare module 'SearchWebPartStrings' {