diff --git a/samples/react-search-refiners/README.md b/samples/react-search-refiners/README.md index d0651a904..e4d8579aa 100644 --- a/samples/react-search-refiners/README.md +++ b/samples/react-search-refiners/README.md @@ -7,8 +7,10 @@ This sample shows you how to build user friendly SharePoint search experiences u

+An associated [blog post](http://thecollaborationcorner.com/2017/10/16/build-dynamic-sharepoint-search-experiences-with-refiners-and-paging-with-spfx-office-ui-fabric-and-pnp-js-library/) is available to give you more details about this sample implementation. + ## Used SharePoint Framework Version -![drop](https://img.shields.io/badge/drop-1.3.0-green.svg) +![drop](https://img.shields.io/badge/drop-1.3.4-green.svg) ## Applies to @@ -25,7 +27,7 @@ react-search-refiners | Franck Cornu (MVP Office Development at aequos) - Twitte Version|Date|Comments -------|----|-------- -1.0 | October 17, 2017 | Initial release +1.0 | January 03, 2018 | Initial release ## 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.** @@ -49,7 +51,8 @@ The following settings are available in the Web Part property pane: Setting | Description -------|---- -Search query | The search query in KQL format. You can use search query variables (See this [post](http://www.techmikael.com/2015/07/sharepoint-rest-do-support-query.html) to know which ones are allowed). +Search query keywords | The search query in KQL format. You can use search query variables (See this [post](http://www.techmikael.com/2015/07/sharepoint-rest-do-support-query.html) to know which ones are allowed). +Query template | The search query template in KQL format. You can use search variables here (like Path:{Site}). Selected properties | The search managed properties to retrieve. You can use these proeprties then in the code like this (`item.property_name`). (See the *Tile.tsx* file) . Refiners | The search managed properties to use as refiners. Make sure these are refinable. With SharePoint Online, you have to reuse the default ones to do so (RefinableStringXX etc.). The order is the same as they will appear in the refnement panel. Number of items to retrieve per page | Quite explicit. The paging behavior is done directly by the search API (See the *SearchDataProvider.ts* file), not by the code on post-render. diff --git a/samples/react-search-refiners/config/package-solution.json b/samples/react-search-refiners/config/package-solution.json index 85f5d24f4..1e6561116 100644 --- a/samples/react-search-refiners/config/package-solution.json +++ b/samples/react-search-refiners/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "PnP - Search and Refiners Web Part", "id": "890affef-33e0-4d72-bd72-36399e02143b", - "version": "1.0.0.1" + "version": "1.0.0.0" }, "paths": { "zippedPackage": "solution/pnp-react-search-refiners.sppkg" diff --git a/samples/react-search-refiners/images/property_pane.png b/samples/react-search-refiners/images/property_pane.png index 6a168578a..3e1d4b3ef 100644 Binary files a/samples/react-search-refiners/images/property_pane.png and b/samples/react-search-refiners/images/property_pane.png differ diff --git a/samples/react-search-refiners/images/react-search-refiners.gif b/samples/react-search-refiners/images/react-search-refiners.gif index 6bdeded9d..60ec26953 100644 Binary files a/samples/react-search-refiners/images/react-search-refiners.gif and b/samples/react-search-refiners/images/react-search-refiners.gif differ diff --git a/samples/react-search-refiners/package.json b/samples/react-search-refiners/package.json index 2265e13b6..8cee1642b 100644 --- a/samples/react-search-refiners/package.json +++ b/samples/react-search-refiners/package.json @@ -11,11 +11,11 @@ "test": "gulp test" }, "dependencies": { - "@microsoft/sp-core-library": "~1.3.0", - "@microsoft/sp-lodash-subset": "~1.3.0", - "@microsoft/sp-webpart-base": "~1.3.0", - "@pnp/spfx-controls-react": "^1.0.0-beta.6", - "@pnp/spfx-property-controls": "1.0.0-beta.2", + "@microsoft/sp-core-library": "~1.3.4", + "@microsoft/sp-lodash-subset": "~1.3.4", + "@microsoft/sp-webpart-base": "~1.3.4", + "@pnp/spfx-controls-react": "^1.0.0-beta.8", + "@pnp/spfx-property-controls": "1.0.0", "@types/react": "15.0.38", "@types/react-addons-shallow-compare": "0.14.17", "@types/react-addons-test-utils": "0.14.15", @@ -30,12 +30,12 @@ "react-custom-scrollbars": "4.1.2", "react-dom": "15.4.2", "react-js-pagination": "3.0.0", - "sp-pnp-js": "3.0.1" + "sp-pnp-js": "3.0.3" }, "devDependencies": { - "@microsoft/sp-build-web": "~1.3.0", - "@microsoft/sp-module-interfaces": "~1.3.0", - "@microsoft/sp-webpart-workbench": "~1.3.0", + "@microsoft/sp-build-web": "~1.3.4", + "@microsoft/sp-module-interfaces": "~1.3.4", + "@microsoft/sp-webpart-workbench": "~1.3.4", "gulp": "~3.9.1", "@types/chai": ">=3.4.34 <3.6.0", "@types/mocha": ">=2.2.33 <2.6.0" diff --git a/samples/react-search-refiners/src/webparts/dataProviders/ISearchDataProvider.ts b/samples/react-search-refiners/src/webparts/dataProviders/ISearchDataProvider.ts index 93bdcea66..3399c2f07 100644 --- a/samples/react-search-refiners/src/webparts/dataProviders/ISearchDataProvider.ts +++ b/samples/react-search-refiners/src/webparts/dataProviders/ISearchDataProvider.ts @@ -3,15 +3,26 @@ import { ISearchResults, IRefinementFilter } from "../models/ISearchResult"; interface ISearchDataProvider { /** - * Determines the number of items ot retrieve in search REST requests + * Determines the number of items ot retrieve in REST requests */ resultsCount: number; + + /** + * Selected managed properties to retrieve when a search query is performed + */ selectedProperties: string[]; + + /** + * Determines the query template to apply in REST requests + */ + queryTemplate?: string; /** - * Performs a SharePoint search query + * Perfoms a search query. + * @returns ISearchResults object. Use the "RelevantResults" property to acces results proeprties (returned as key/value pair object => item.[]) */ search(kqlQuery: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise; + } export default ISearchDataProvider; \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/dataProviders/SearchDataProvider.ts b/samples/react-search-refiners/src/webparts/dataProviders/SearchDataProvider.ts index 448aa4a1f..c75ba2119 100644 --- a/samples/react-search-refiners/src/webparts/dataProviders/SearchDataProvider.ts +++ b/samples/react-search-refiners/src/webparts/dataProviders/SearchDataProvider.ts @@ -15,6 +15,7 @@ class SearchDataProvider implements ISearchDataProvider { private _context: IWebPartContext; private _appSearchSettings: SearchQuery; private _selectedProperties: string[]; + private _queryTemplate: string; public get resultsCount(): number { return this._resultsCount; } public set resultsCount(value: number) { this._resultsCount = value; } @@ -22,6 +23,9 @@ class SearchDataProvider implements ISearchDataProvider { public set selectedProperties(value: string[]) { this._selectedProperties = value; } public get selectedProperties(): string[] { return this._selectedProperties; } + public set queryTemplate(value: string) { this._queryTemplate = value; } + public get queryTemplate(): string { return this._queryTemplate; } + public constructor(webPartContext: IWebPartContext) { this._context = webPartContext; @@ -53,15 +57,16 @@ class SearchDataProvider implements ISearchDataProvider { let sortedRefiners: string[] = []; // Search paging option is one based - let page = pageNumber ? pageNumber: 1; + let page = pageNumber ? pageNumber : 1; searchQuery.ClientType = "ContentSearchRegular"; + searchQuery.Querytext = query; // To be able to use search query variable according to the current context // http://www.techmikael.com/2015/07/sharepoint-rest-do-support-query.html - searchQuery.QueryTemplate = query ? query : "";; + searchQuery.QueryTemplate = this._queryTemplate; - searchQuery.RowLimit = this._resultsCount; + searchQuery.RowLimit = this._resultsCount ? this._resultsCount : 50; searchQuery.SelectProperties = this._selectedProperties; searchQuery.TrimDuplicates = false; @@ -130,7 +135,7 @@ class SearchDataProvider implements ISearchDataProvider { }); // Get the icon source URL - this._mapToIcon(result.Filename).then((iconUrl) => { + this._mapToIcon(result.Filename ? result.Filename : Text.format(".{0}", result.FileExtension)).then((iconUrl) => { result.iconSrc = iconUrl; resolvep1(result); @@ -150,7 +155,7 @@ class SearchDataProvider implements ISearchDataProvider { refiner.Entries.map((item) => { values.push({ RefinementCount: item.RefinementCount, - RefinementName: this._formatDate(item.RefinementName), //This value will appear in the selected filter bar + RefinementName: this._formatDate(item.RefinementName), // This value will appear in the selected filter bar RefinementToken: item.RefinementToken, RefinementValue: this._formatDate(item.RefinementValue), // This value will appear in the filter panel }); @@ -240,7 +245,7 @@ class SearchDataProvider implements ISearchDataProvider { return filter.Value.RefinementToken; }); - return refinementFilter.length > 1 ? "or(" + refinementFilter + ")" : refinementFilter.toString(); + return refinementFilter.length > 1 ? Text.format("or({0})", refinementFilter) : refinementFilter.toString(); }); mapKeys(refinementFilters, (value, key) => { @@ -265,7 +270,7 @@ class SearchDataProvider implements ISearchDataProvider { // Multiple filters case (conditionsCount > 1): { - refinementQueryString = "and(" + refinementQueryConditions.toString() + ")"; + refinementQueryString = Text.format("and({0})", refinementQueryConditions.toString()); break; } } diff --git a/samples/react-search-refiners/src/webparts/search/ISearchWebPartProps.ts b/samples/react-search-refiners/src/webparts/search/ISearchWebPartProps.ts index c1174db45..dafb026ee 100644 --- a/samples/react-search-refiners/src/webparts/search/ISearchWebPartProps.ts +++ b/samples/react-search-refiners/src/webparts/search/ISearchWebPartProps.ts @@ -1,5 +1,6 @@ export interface ISearchWebPartProps { - searchQuery: string; + queryKeywords: string; + queryTemplate: string; maxResultsCount: number; selectedProperties: string; refiners: string; diff --git a/samples/react-search-refiners/src/webparts/search/SearchWebPart.manifest.json b/samples/react-search-refiners/src/webparts/search/SearchWebPart.manifest.json index c6cf128d4..0c27d6949 100644 --- a/samples/react-search-refiners/src/webparts/search/SearchWebPart.manifest.json +++ b/samples/react-search-refiners/src/webparts/search/SearchWebPart.manifest.json @@ -20,7 +20,8 @@ "description": { "default": "Displays search results with customizable dynamic refiners" }, "officeFabricIconFontName": "Search", "properties": { - "searchQuery": "", + "queryKeywords": "", + "queryTemplate": "", "refiners": "", "selectedProperties": "", "maxResultsCount": 10 diff --git a/samples/react-search-refiners/src/webparts/search/SearchWebPart.ts b/samples/react-search-refiners/src/webparts/search/SearchWebPart.ts index c794fa628..86de46b33 100644 --- a/samples/react-search-refiners/src/webparts/search/SearchWebPart.ts +++ b/samples/react-search-refiners/src/webparts/search/SearchWebPart.ts @@ -46,23 +46,23 @@ export default class SearchWebPart extends BaseClientSideWebPart = React.createElement( SearchContainer, { - dataProvider: this._dataProvider, - searchQuery: this.properties.searchQuery, + searchDataProvider: this._dataProvider, + queryKeywords: this.properties.queryKeywords, maxResultsCount: this.properties.maxResultsCount, - selectedProperties: this.properties.selectedProperties, + selectedProperties: this.properties.selectedProperties ? this.properties.selectedProperties.replace(/\s|,+$/g,'').split(",") :[], refiners: this.properties.refiners, showPaging: this.properties.showPaging, - } + } as ISearchContainerProps ); const placeholder: React.ReactElement = React.createElement( @@ -76,7 +76,7 @@ export default class SearchWebPart extends BaseClientSideWebPart { - private _initialFilters: IRefinementResult[]; - public constructor(props) { super(props); - // The initialFilters are just set once and never updated afterwards so we don't need to put them in the component state. - // We dont' want the refiners update every time to be able to revert changes easily in the interface and don't lose initial refiners. - this._initialFilters = this.props.availableFilters; - this.state = { showPanel: false, selectedFilters: [], @@ -52,7 +47,7 @@ export default class FilterPanel extends React.Component { + this.props.availableFilters.map((filter, i) => { groups.push({ key: i.toString(), @@ -76,12 +71,12 @@ export default class FilterPanel extends React.Component { + label={ Text.format(refinementValue.RefinementValue + " ({0})", refinementValue.RefinementCount)} + onChange= {(ev, checked: boolean) => { // Every time we chek/uncheck a filter, a complete new search request is performed with current selected refiners checked ? this._addFilter(currentRefinement): this._removeFilter(currentRefinement); }} /> @@ -96,13 +91,10 @@ export default class FilterPanel extends React.Component { return ( - { this._removeFilter(filter); }} - /> + ); }); @@ -129,7 +121,6 @@ export default class FilterPanel extends React.Component 0) ?
- { renderSelectedFilters }
: null @@ -137,16 +128,17 @@ export default class FilterPanel extends React.Component { - if(this._initialFilters.length > 0) { + if (this.props.availableFilters.length > 0) { return (
@@ -245,7 +237,7 @@ export default class FilterPanel extends React.Component { + this.props.availableFilters.map((filter) => { filter.Values.map((refinementValue: IRefinementValue, index) => { allFilters.push({FilterName: filter.FilterName, Value: refinementValue}); diff --git a/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerProps.ts b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerProps.ts index 72b2956b8..8d8bd5210 100644 --- a/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerProps.ts +++ b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerProps.ts @@ -1,8 +1,8 @@ import ISearchDataProvider from "../../../dataProviders/ISearchDataProvider"; interface ISearchContainerProps { - dataProvider: ISearchDataProvider; - searchQuery: string; + searchDataProvider: ISearchDataProvider; + queryKeywords: string; maxResultsCount: number; selectedProperties: string[]; refiners: string; diff --git a/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerState.ts b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerState.ts index 4ee8141ab..02f4a4177 100644 --- a/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerState.ts +++ b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerState.ts @@ -1,12 +1,46 @@ -import { ISearchResults, IRefinementFilter } from "../../../models/ISearchResult"; +import { ISearchResults, IRefinementFilter, IRefinementResult } from "../../../models/ISearchResult"; interface ISearchContainerState { - results?: ISearchResults; - selectedFilters?: IRefinementFilter[]; - currentPage?: number; - errorMessage?: string; - hasError?: boolean; - areResultsLoading?: boolean; + + /** + * The current search results to display + */ + results: ISearchResults; + + /** + * Current selected filters to apply to the search query. We need this information during page transition to keep existing filters + */ + selectedFilters: IRefinementFilter[]; + + /** + * Available filters in the filter panel + */ + availableFilters: IRefinementResult[]; + + /** + * The current result page number + */ + currentPage: number; + + /** + * Error message display in the message bar + */ + errorMessage: string; + + /** + * Indicates whether or not there is an error in the component + */ + hasError: boolean; + + /** + * Indicates whether or not the resutls arre currenty loading due to a refinement or new query + */ + areResultsLoading: boolean; + + /** + * Indicates whether or not the componetn loads for the first time + */ + isComponentLoading: boolean; } export default ISearchContainerState; \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/search/components/SearchContainer/SearchContainer.tsx b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/SearchContainer.tsx index 8785a817d..48c1a7b96 100644 --- a/samples/react-search-refiners/src/webparts/search/components/SearchContainer/SearchContainer.tsx +++ b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/SearchContainer.tsx @@ -10,6 +10,7 @@ import TilesList from "../TilesList/TilesList"; import "../SearchWebPart.scss"; import FilterPanel from "../FilterPanel/FilterPanel"; import Paging from "../Paging/Paging"; +import { Overlay } from "office-ui-fabric-react"; export default class SearchContainer extends React.Component { @@ -24,8 +25,10 @@ export default class SearchContainer extends React.Component + + +
; + } - if (areResultsLoading) { + if (isComponentLoading) { wpContent = ; } else { if (hasError) { - wpContent = { errorMessage }; - } else { if (items.RelevantResults.length === 0) { - wpContent =
- +
{ strings.NoResultMessage }
; - } else { - wpContent =
- + + { renderOverlay } { this.props.showPaging ? { + try { this.setState({ - results: searchResults, - areResultsLoading: false, + areResultsLoading: true, }); - }).catch((error) => { - Logger.write("[SearchContainer._getSearchResults()]: Error: " + error, LogLevel.Error); + const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, this.props.refiners, this.state.selectedFilters, this.state.currentPage); + + // 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, + availableFilters: searchResults.RefinementResults, + areResultsLoading: false, + isComponentLoading: false, + }); + + } catch (error) { + + Logger.write("[SearchContainer._componentDidMount()]: Error: " + error, LogLevel.Error); this.setState({ areResultsLoading: false, + isComponentLoading: false, results: { RefinementResults: [], RelevantResults: [] }, hasError: true, - errorMessage: error.message, + errorMessage: error.message }); - }); + } + } + + public async componentWillReceiveProps(nextProps: ISearchContainerProps) { + + // New props are passed to the component when the search query has been changed + if (this.props.refiners.toString() !== nextProps.refiners.toString() + || this.props.maxResultsCount !== nextProps.maxResultsCount) { + + try { + + // Clear selected filters on a new query or new refiners + this.setState({ + selectedFilters: [], + areResultsLoading: true, + }); + + // We reset the page number and refinement filters + const searchResults = await this.props.searchDataProvider.search(nextProps.queryKeywords, nextProps.refiners, [], 1); + + this.setState({ + results: searchResults, + availableFilters: searchResults.RefinementResults, + areResultsLoading: false, + }); + + } catch (error) { + + Logger.write("[SearchContainer._componentWillReceiveProps()]: Error: " + error, LogLevel.Error); + + this.setState({ + areResultsLoading: false, + isComponentLoading: false, + results: { RefinementResults: [], RelevantResults: [] }, + hasError: true, + errorMessage: error.message + }); + } + } } /** * Callback function to apply new filters coming from the filter panel child component * @param newFilters The new filters to apply */ - private _onUpdateFilters(newFilters: IRefinementFilter[]) { - - this._getSearchResults(this.props.searchQuery, this.props.refiners, newFilters, 1); + private async _onUpdateFilters(newFilters: IRefinementFilter[]) { + // Get back to the first page when new filters have been selected this.setState({ selectedFilters: newFilters, currentPage: 1, + areResultsLoading: true, + }); + + const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, this.props.refiners, newFilters, 1); + + this.setState({ + results: searchResults, + areResultsLoading: false, }); } @@ -144,11 +193,18 @@ export default class SearchContainer extends React.Component