diff --git a/samples/react-search-refiners/.editorconfig b/samples/react-search-refiners/.editorconfig
new file mode 100644
index 000000000..8ffcdc4ec
--- /dev/null
+++ b/samples/react-search-refiners/.editorconfig
@@ -0,0 +1,25 @@
+# EditorConfig helps developers define and maintain consistent
+# coding styles between different editors and IDEs
+# editorconfig.org
+
+root = true
+
+
+[*]
+
+# change these settings to your own preference
+indent_style = space
+indent_size = 2
+
+# we recommend you to keep these unchanged
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[{package,bower}.json]
+indent_style = space
+indent_size = 2
\ No newline at end of file
diff --git a/samples/react-search-refiners/.gitignore b/samples/react-search-refiners/.gitignore
new file mode 100644
index 000000000..b19bbe123
--- /dev/null
+++ b/samples/react-search-refiners/.gitignore
@@ -0,0 +1,32 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+
+# Dependency directories
+node_modules
+
+# Build generated files
+dist
+lib
+solution
+temp
+*.sppkg
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# OSX
+.DS_Store
+
+# Visual Studio files
+.ntvs_analysis.dat
+.vs
+bin
+obj
+
+# Resx Generated Code
+*.resx.ts
+
+# Styles Generated Code
+*.scss.ts
diff --git a/samples/react-search-refiners/.yo-rc.json b/samples/react-search-refiners/.yo-rc.json
new file mode 100644
index 000000000..cd484cd53
--- /dev/null
+++ b/samples/react-search-refiners/.yo-rc.json
@@ -0,0 +1,8 @@
+{
+ "@microsoft/generator-sharepoint": {
+ "version": "1.3.2",
+ "libraryName": "react-search-refiners",
+ "libraryId": "890affef-33e0-4d72-bd72-36399e02143b",
+ "environment": "spo"
+ }
+}
\ No newline at end of file
diff --git a/samples/react-search-refiners/README.md b/samples/react-search-refiners/README.md
new file mode 100644
index 000000000..d0651a904
--- /dev/null
+++ b/samples/react-search-refiners/README.md
@@ -0,0 +1,66 @@
+# SharePoint Framework search with refiners and paging sample
+
+## Summary
+This sample shows you how to build user friendly SharePoint search experiences using Office UI fabric tiles, custom refiners and paging.
+
+
+
+
+
+## Used SharePoint Framework Version
+![drop](https://img.shields.io/badge/drop-1.3.0-green.svg)
+
+## Applies to
+
+* [SharePoint Framework](https:/dev.office.com/sharepoint)
+* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
+
+## Solution
+
+Solution|Author(s)
+--------|---------
+react-search-refiners | Franck Cornu (MVP Office Development at aequos) - Twitter @FranckCornu
+
+## Version history
+
+Version|Date|Comments
+-------|----|--------
+1.0 | October 17, 2017 | 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.**
+
+---
+
+## Minimal Path to Awesome
+
+- Clone this repository
+- In the command line run:
+ - `npm install`
+ - `gulp serve`
+
+### Web Part property pane options
+
+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).
+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.
+Show paging | Indicates whether or not the component should show the paging control at the bottom.
+
+## Features
+This Web Part illustrates the following concepts on top of the SharePoint Framework:
+
+- Build an user friendly search experience on the top of the SharePoint search REST API with paging and refiners using the *sp-pnp-js* library.
+- 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).
+
+
\ No newline at end of file
diff --git a/samples/react-search-refiners/config/config.json b/samples/react-search-refiners/config/config.json
new file mode 100644
index 000000000..799f58904
--- /dev/null
+++ b/samples/react-search-refiners/config/config.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://dev.office.com/json-schemas/spfx-build/config.2.0.schema.json",
+ "version": "2.0",
+ "bundles": {
+ "search-web-part": {
+ "components": [
+ {
+ "entrypoint": "./lib/webparts/search/SearchWebPart.js",
+ "manifest": "./src/webparts/search/SearchWebPart.manifest.json"
+ }
+ ]
+ }
+ },
+ "externals": {},
+ "localizedResources": {
+ "SearchWebPartStrings": "lib/webparts/search/loc/{locale}.js",
+ "PropertyControlStrings": "./node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js"
+ }
+}
diff --git a/samples/react-search-refiners/config/copy-assets.json b/samples/react-search-refiners/config/copy-assets.json
new file mode 100644
index 000000000..e1bb26179
--- /dev/null
+++ b/samples/react-search-refiners/config/copy-assets.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://dev.office.com/json-schemas/spfx-build/copy-assets.schema.json",
+ "deployCdnPath": "temp/deploy"
+}
diff --git a/samples/react-search-refiners/config/deploy-azure-storage.json b/samples/react-search-refiners/config/deploy-azure-storage.json
new file mode 100644
index 000000000..4a9871450
--- /dev/null
+++ b/samples/react-search-refiners/config/deploy-azure-storage.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "https://dev.office.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
+ "workingDir": "./temp/deploy/",
+ "account": "",
+ "container": "react-search-refiners",
+ "accessKey": ""
+}
\ No newline at end of file
diff --git a/samples/react-search-refiners/config/package-solution.json b/samples/react-search-refiners/config/package-solution.json
new file mode 100644
index 000000000..85f5d24f4
--- /dev/null
+++ b/samples/react-search-refiners/config/package-solution.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://dev.office.com/json-schemas/spfx-build/package-solution.schema.json",
+ "solution": {
+ "name": "PnP - Search and Refiners Web Part",
+ "id": "890affef-33e0-4d72-bd72-36399e02143b",
+ "version": "1.0.0.1"
+ },
+ "paths": {
+ "zippedPackage": "solution/pnp-react-search-refiners.sppkg"
+ }
+}
diff --git a/samples/react-search-refiners/config/serve.json b/samples/react-search-refiners/config/serve.json
new file mode 100644
index 000000000..0eb6d456c
--- /dev/null
+++ b/samples/react-search-refiners/config/serve.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://dev.office.com/json-schemas/core-build/serve.schema.json",
+ "port": 4321,
+ "https": true,
+ "initialPage": "https://localhost:5432/workbench",
+ "api": {
+ "port": 5432,
+ "entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
+ }
+}
diff --git a/samples/react-search-refiners/config/tslint.json b/samples/react-search-refiners/config/tslint.json
new file mode 100644
index 000000000..0bb934c20
--- /dev/null
+++ b/samples/react-search-refiners/config/tslint.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "https://dev.office.com/json-schemas/core-build/tslint.schema.json",
+ // Display errors as warnings
+ "displayAsWarning": true,
+ // The TSLint task may have been configured with several custom lint rules
+ // before this config file is read (for example lint rules from the tslint-microsoft-contrib
+ // project). If true, this flag will deactivate any of these rules.
+ "removeExistingRules": true,
+ // When true, the TSLint task is configured with some default TSLint "rules.":
+ "useDefaultConfigAsBase": false,
+ // Since removeExistingRules=true and useDefaultConfigAsBase=false, there will be no lint rules
+ // which are active, other than the list of rules below.
+ "lintConfig": {
+ // Opt-in to Lint rules which help to eliminate bugs in JavaScript
+ "rules": {
+ "class-name": false,
+ "export-name": false,
+ "forin": false,
+ "label-position": false,
+ "member-access": true,
+ "no-arg": false,
+ "no-console": false,
+ "no-construct": false,
+ "no-duplicate-case": true,
+ "no-duplicate-variable": true,
+ "no-eval": false,
+ "no-function-expression": true,
+ "no-internal-module": true,
+ "no-shadowed-variable": true,
+ "no-switch-case-fall-through": true,
+ "no-unnecessary-semicolons": true,
+ "no-unused-expression": true,
+ "no-use-before-declare": true,
+ "no-with-statement": true,
+ "semicolon": true,
+ "trailing-comma": false,
+ "typedef": false,
+ "typedef-whitespace": false,
+ "use-named-parameter": true,
+ "valid-typeof": true,
+ "variable-name": false,
+ "whitespace": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/react-search-refiners/config/write-manifests.json b/samples/react-search-refiners/config/write-manifests.json
new file mode 100644
index 000000000..3506b9ea5
--- /dev/null
+++ b/samples/react-search-refiners/config/write-manifests.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://dev.office.com/json-schemas/spfx-build/write-manifests.schema.json",
+ "cdnBasePath": ""
+}
\ No newline at end of file
diff --git a/samples/react-search-refiners/gulpfile.js b/samples/react-search-refiners/gulpfile.js
new file mode 100644
index 000000000..7d36ddb1c
--- /dev/null
+++ b/samples/react-search-refiners/gulpfile.js
@@ -0,0 +1,6 @@
+'use strict';
+
+const gulp = require('gulp');
+const build = require('@microsoft/sp-build-web');
+
+build.initialize(gulp);
diff --git a/samples/react-search-refiners/images/property_pane.png b/samples/react-search-refiners/images/property_pane.png
new file mode 100644
index 000000000..6a168578a
Binary files /dev/null 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
new file mode 100644
index 000000000..6bdeded9d
Binary files /dev/null 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
new file mode 100644
index 000000000..2265e13b6
--- /dev/null
+++ b/samples/react-search-refiners/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "react-search-refiners",
+ "version": "0.0.1",
+ "private": true,
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "scripts": {
+ "build": "gulp bundle",
+ "clean": "gulp clean",
+ "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",
+ "@types/react": "15.0.38",
+ "@types/react-addons-shallow-compare": "0.14.17",
+ "@types/react-addons-test-utils": "0.14.15",
+ "@types/react-addons-update": "0.14.14",
+ "@types/react-dom": "0.14.18",
+ "@types/webpack-env": ">=1.12.1 <1.14.0",
+ "immutability-helper": "2.4.0",
+ "lodash-es": "4.17.4",
+ "moment": "2.18.1",
+ "office-ui-fabric-react": "4.40.2-hotfix.1",
+ "react": "15.4.2",
+ "react-custom-scrollbars": "4.1.2",
+ "react-dom": "15.4.2",
+ "react-js-pagination": "3.0.0",
+ "sp-pnp-js": "3.0.1"
+ },
+ "devDependencies": {
+ "@microsoft/sp-build-web": "~1.3.0",
+ "@microsoft/sp-module-interfaces": "~1.3.0",
+ "@microsoft/sp-webpart-workbench": "~1.3.0",
+ "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
new file mode 100644
index 000000000..93bdcea66
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/dataProviders/ISearchDataProvider.ts
@@ -0,0 +1,17 @@
+import { ISearchResults, IRefinementFilter } from "../models/ISearchResult";
+
+interface ISearchDataProvider {
+
+ /**
+ * Determines the number of items ot retrieve in search REST requests
+ */
+ resultsCount: number;
+ selectedProperties: string[];
+
+ /**
+ * Performs a SharePoint search query
+ */
+ 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/MockSearchDataProvider.ts b/samples/react-search-refiners/src/webparts/dataProviders/MockSearchDataProvider.ts
new file mode 100644
index 000000000..7b5f8d285
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/dataProviders/MockSearchDataProvider.ts
@@ -0,0 +1,151 @@
+import ISearchDataProvider from "./ISearchDataProvider";
+import { ISearchResults, IRefinementFilter, ISearchResult } from "../models/ISearchResult";
+import intersection from "lodash-es/intersection";
+import clone from "lodash-es/clone";
+
+class MockSearchDataProvider implements ISearchDataProvider {
+
+ public selectedProperties: string[];
+
+ private _itemsCount: number;
+
+ public get resultsCount(): number { return this._itemsCount; }
+ public set resultsCount(value: number) { this._itemsCount = value; }
+
+ private _searchResults: ISearchResults;
+
+ public constructor() {
+
+ this._searchResults = {
+ RelevantResults: [
+ {
+ Title: "Document 1 - Category 1",
+ Url: "http://document1.ca",
+ Created: "2017-07-22T15:38:54.0000000Z",
+ RefinementTokenValues: "ǂǂ446f63756d656e74,ǂǂ45647563617465",
+ ContentCategory: "Document",
+ },
+ {
+ Title: "Document 2 - Category 2",
+ Url: "http://document2.ca",
+ Created: "2017-07-22T15:38:54.0000000Z",
+ RefinementTokenValues: "ǂǂ446f63756d656e74,ǂǂ416476697365",
+ ContentCategory: "Document",
+ },
+ {
+ Title: "Form 1",
+ Url: "http://form1.ca",
+ Created: "2017-07-22T15:38:54.0000000Z",
+ RefinementTokenValues: "ǂǂ466f726d",
+ ContentCategory: "Form",
+ },
+ {
+ Title: "Video 1 - Category 1",
+ Url: "https://www.youtube.com/watch?v=S93e6UU7y9o",
+ Created: "2017-07-22T15:38:54.0000000Z",
+ RefinementTokenValues: "ǂǂ566964656f,ǂǂ45647563617465",
+ ContentCategory: "Video",
+ },
+ {
+ Title: "Video 2 - Category 2",
+ Url: "https://www.youtube.com/watch?v=8Nl_dKVQ1O8",
+ Created: "2017-07-22T15:38:54.0000000Z",
+ RefinementTokenValues: "ǂǂ566964656f,ǂǂ416476697365",
+ ContentCategory: "Video",
+ },
+ ],
+ RefinementResults: [
+ {
+ FilterName: "Type",
+ Values: [
+ {
+ RefinementCount: 2,
+ RefinementName: "Document",
+ RefinementToken: "ǂǂ446f63756d656e74",
+ RefinementValue: "Document",
+ },
+ {
+ RefinementCount: 2,
+ RefinementName: "Video",
+ RefinementToken: "ǂǂ566964656f",
+ RefinementValue: "Video",
+ },
+ {
+ RefinementCount: 1,
+ RefinementName: "Form",
+ RefinementToken: "ǂǂ466f726d",
+ RefinementValue: "Form",
+ }
+ ]
+ },
+ {
+ FilterName: "Theme",
+ Values: [
+ {
+ RefinementCount: 2,
+ RefinementName: "Category 1",
+ RefinementToken: "ǂǂ45647563617465",
+ RefinementValue: "Category 1",
+ },
+ {
+ RefinementCount: 2,
+ RefinementName: "Category 2",
+ RefinementToken: "ǂǂ416476697365",
+ RefinementValue: "Category 2",
+ },
+ ]
+ }
+ ],
+ TotalRows: 5,
+ };
+ }
+
+ public search(query: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise {
+
+ const p1 = new Promise((resolve, reject) => {
+
+ const filters: string[] = [];
+ let searchResults = clone(this._searchResults);
+ const filteredResults: ISearchResult[] = [];
+
+ if (refinementFilters.length > 0) {
+ refinementFilters.map((filter) => {
+ filters.push(filter.Value.RefinementToken);
+ });
+
+ searchResults.RelevantResults.map((searchResult) => {
+ const filtered = intersection(filters, searchResult.RefinementTokenValues.split(","));
+ if (filtered.length > 0) {
+ filteredResults.push(searchResult);
+ }
+ });
+
+ searchResults = {
+ RelevantResults: filteredResults,
+ RefinementResults: this._searchResults.RefinementResults,
+ TotalRows: filteredResults.length,
+ };
+ }
+
+ // Return only the specified count
+ searchResults.RelevantResults = this._paginate(searchResults.RelevantResults, this._itemsCount, pageNumber);
+
+ // Simulate an async call
+ setTimeout(() => {
+ resolve(searchResults);
+ }, 1000);
+ });
+
+ return p1;
+ }
+
+ private _paginate (array, pageSize: number, pageNumber: number) {
+ let basePage = --pageNumber * pageSize;
+
+ return pageNumber < 0 || pageSize < 1 || basePage >= array.length
+ ? []
+ : array.slice(basePage, basePage + pageSize );
+ }
+}
+
+export default MockSearchDataProvider;
\ 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
new file mode 100644
index 000000000..448aa4a1f
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/dataProviders/SearchDataProvider.ts
@@ -0,0 +1,277 @@
+import ISearchDataProvider from "./ISearchDataProvider";
+import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter } from "../models/ISearchResult";
+import pnp, { ConsoleListener, Logger, LogLevel, SearchQuery, SearchQueryBuilder, SearchResults, setup, Web, Sort, SortDirection } from "sp-pnp-js";
+import { IWebPartContext } from "@microsoft/sp-webpart-base";
+import { Text, JsonUtilities, UrlUtilities } 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 * as moment from "moment";
+
+class SearchDataProvider implements ISearchDataProvider {
+
+ private _resultsCount: number;
+ private _context: IWebPartContext;
+ private _appSearchSettings: SearchQuery;
+ private _selectedProperties: string[];
+
+ public get resultsCount(): number { return this._resultsCount; }
+ public set resultsCount(value: number) { this._resultsCount = value; }
+
+ public set selectedProperties(value: string[]) { this._selectedProperties = value; }
+ public get selectedProperties(): string[] { return this._selectedProperties; }
+
+ public constructor(webPartContext: IWebPartContext) {
+ this._context = webPartContext;
+
+ // Setup the PnP JS instance
+ const consoleListener = new ConsoleListener();
+ Logger.subscribe(consoleListener);
+
+ // To limit the payload size, we set odata=nometadata
+ // We just need to get list items here
+ // We also set the SPFx context accordingly (https://github.com/SharePoint/PnP-JS-Core/wiki/Using-sp-pnp-js-in-SharePoint-Framework)
+ setup({
+ sp: {
+ headers: {
+ Accept: "application/json; odata=nometadata",
+ },
+ },
+ spfxContext: this._context,
+ });
+ }
+
+ /**
+ * Performs a search query against SharePoint
+ * @param query The search query in KQL format
+ * @return The search results
+ */
+ public async search(query: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise {
+
+ let searchQuery: SearchQuery = {};
+ let sortedRefiners: string[] = [];
+
+ // Search paging option is one based
+ let page = pageNumber ? pageNumber: 1;
+
+ searchQuery.ClientType = "ContentSearchRegular";
+
+ // 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.RowLimit = this._resultsCount;
+ searchQuery.SelectProperties = this._selectedProperties;
+ searchQuery.TrimDuplicates = false;
+
+ let sortList: Sort[] = [
+ {
+ Property: "Created",
+ Direction: SortDirection.Descending
+ },
+ {
+ Property: "Size",
+ Direction: SortDirection.Ascending
+ }
+ ];
+
+ searchQuery.SortList = sortList;
+
+ if (refiners) {
+ // Get the refiners order specified in the property pane
+ sortedRefiners = refiners.split(",");
+ searchQuery.Refiners = refiners ? refiners : "";
+ }
+
+ if (refinementFilters) {
+ if (refinementFilters.length > 0) {
+ searchQuery.RefinementFilters = [this._buildRefinementQueryString(refinementFilters)];
+ }
+ }
+
+ let results: ISearchResults = {
+ RelevantResults : [],
+ RefinementResults: [],
+ TotalRows: 0,
+ };
+
+ try {
+
+ const r = await pnp.sp.search(searchQuery);
+
+ const allItemsPromises: Promise[] = [];
+ let refinementResults: IRefinementResult[] = [];
+
+ // Need to do this check
+ // More info here: https://github.com/SharePoint/PnP-JS-Core/issues/337
+ if (r.RawSearchResults.PrimaryQueryResult) {
+
+ // Be careful, there was an issue with paging calculation under 2.0.8 version of sp-pnp-js library
+ // More info https://github.com/SharePoint/PnP-JS-Core/issues/535
+ const r2 = await r.getPage(page, this._resultsCount);
+
+ const resultRows = r2.RawSearchResults.PrimaryQueryResult.RelevantResults.Table.Rows;
+ let refinementResultsRows = r2.RawSearchResults.PrimaryQueryResult.RefinementResults;
+
+ const refinementRows = refinementResultsRows ? refinementResultsRows["Refiners"] : [];
+
+ // Map search results
+ resultRows.map((elt) => {
+
+ const p1 = new Promise((resolvep1, rejectp1) => {
+
+ // Build item result dynamically
+ // We can't type the response here because search results are by definition too heterogeneous so we treat them as key-value object
+ let result: ISearchResult = {};
+
+ elt.Cells.map((item) => {
+ result[item.Key] = item.Value;
+ });
+
+ // Get the icon source URL
+ this._mapToIcon(result.Filename).then((iconUrl) => {
+
+ result.iconSrc = iconUrl;
+ resolvep1(result);
+
+ }).catch((error) => {
+ rejectp1(error);
+ });
+ });
+
+ allItemsPromises.push(p1);
+ });
+
+ // Map refinement results
+ refinementRows.map((refiner) => {
+
+ let values: IRefinementValue[] = [];
+ refiner.Entries.map((item) => {
+ values.push({
+ RefinementCount: item.RefinementCount,
+ 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
+ });
+ });
+
+ refinementResults.push({
+ FilterName: refiner.Name,
+ Values: values,
+ });
+ });
+
+ // Resolve all the promises once to get news
+ const relevantResults: ISearchResult[] = await Promise.all(allItemsPromises);
+
+ // Sort refiners according to the property pane value
+ refinementResults = sortBy(refinementResults, (refinement) => {
+
+ // Get the index of the corresponding filter name
+ return sortedRefiners.indexOf(refinement.FilterName);
+ });
+
+ results.RelevantResults = relevantResults;
+ results.RefinementResults = refinementResults,
+ results.TotalRows = r.TotalRows;
+ }
+ return results;
+
+ } catch (error) {
+ Logger.write("[SharePointDataProvider.search()]: Error: " + error, LogLevel.Error);
+ throw error;
+ }
+ }
+
+ /**
+ * Gets the icon corresponding to the file name extension
+ * @param filename The file name (ex: file.pdf)
+ */
+ private async _mapToIcon(filename: string): Promise {
+
+ 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 iconUrl = webAbsoluteUrl + "/_layouts/15/images/" + iconFileName;
+
+ return iconUrl;
+
+ } catch (error) {
+ Logger.write("[SharePointDataProvider._mapToIcon()]: Error: " + error, LogLevel.Error);
+ throw error;
+ }
+ }
+
+ /**
+ * Find and eeplace ISO 8601 dates in the string by a friendly value
+ * @param inputValue The string to format
+ */
+ private _formatDate(inputValue: string): string {
+
+ const iso8061rgx = /(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/g;
+ const matches = inputValue.match(iso8061rgx);
+
+ let updatedInputValue = inputValue;
+
+ if (matches) {
+ matches.map(match => {
+ updatedInputValue = updatedInputValue.replace(match, moment(match).format("LL"));
+ });
+ }
+
+ return updatedInputValue;
+ }
+
+ /**
+ * Build the refinement condition in FQL format
+ * @param selectedFilters The selected filter array
+ */
+ private _buildRefinementQueryString(selectedFilters: IRefinementFilter[]): string {
+
+ let refinementQueryConditions: string[] = [];
+ let refinementQueryString: string = null;
+
+ const refinementFilters = mapValues(groupBy(selectedFilters, 'FilterName'), (values) => {
+ const refinementFilter = values.map((filter) => {
+ return filter.Value.RefinementToken;
+ });
+
+ return refinementFilter.length > 1 ? "or(" + refinementFilter + ")" : refinementFilter.toString();
+ });
+
+ mapKeys(refinementFilters, (value, key) => {
+ refinementQueryConditions.push(key + ":" + value);
+ });
+
+ const conditionsCount = refinementQueryConditions.length;
+
+ switch (true) {
+
+ // No filters
+ case (conditionsCount === 0): {
+ refinementQueryString = null;
+ break;
+ }
+
+ // Just one filter
+ case (conditionsCount === 1): {
+ refinementQueryString = refinementQueryConditions[0].toString();
+ break;
+ }
+
+ // Multiple filters
+ case (conditionsCount > 1): {
+ refinementQueryString = "and(" + refinementQueryConditions.toString() + ")";
+ break;
+ }
+ }
+
+ return refinementQueryString;
+ }
+}
+
+export default SearchDataProvider;
\ No newline at end of file
diff --git a/samples/react-search-refiners/src/webparts/models/ISearchResult.ts b/samples/react-search-refiners/src/webparts/models/ISearchResult.ts
new file mode 100644
index 000000000..b8aaf0afa
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/models/ISearchResult.ts
@@ -0,0 +1,27 @@
+export interface ISearchResults {
+ RelevantResults: ISearchResult[];
+ RefinementResults: IRefinementResult[];
+ TotalRows?: number;
+}
+
+export interface ISearchResult {
+ [key: string]: string;
+ IconSrc?: string;
+}
+
+export interface IRefinementResult {
+ FilterName: string;
+ Values: IRefinementValue[];
+}
+
+export interface IRefinementValue {
+ RefinementCount: number;
+ RefinementName: string;
+ RefinementToken: string;
+ RefinementValue: string;
+}
+
+export interface IRefinementFilter {
+ FilterName: string;
+ Value: IRefinementValue;
+}
\ No newline at end of file
diff --git a/samples/react-search-refiners/src/webparts/models/RefinementValueOperationCallback.ts b/samples/react-search-refiners/src/webparts/models/RefinementValueOperationCallback.ts
new file mode 100644
index 000000000..fbc07b974
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/models/RefinementValueOperationCallback.ts
@@ -0,0 +1,5 @@
+import { IRefinementFilter } from "./ISearchResult";
+
+type RefinementFilterOperationCallback = (filters: IRefinementFilter[]) => void;
+
+export default RefinementFilterOperationCallback;
\ No newline at end of file
diff --git a/samples/react-search-refiners/src/webparts/search/ISearchWebPartProps.ts b/samples/react-search-refiners/src/webparts/search/ISearchWebPartProps.ts
new file mode 100644
index 000000000..c1174db45
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/search/ISearchWebPartProps.ts
@@ -0,0 +1,7 @@
+export interface ISearchWebPartProps {
+ searchQuery: string;
+ maxResultsCount: number;
+ selectedProperties: string;
+ refiners: string;
+ showPaging: boolean;
+}
diff --git a/samples/react-search-refiners/src/webparts/search/SearchWebPart.manifest.json b/samples/react-search-refiners/src/webparts/search/SearchWebPart.manifest.json
new file mode 100644
index 000000000..c6cf128d4
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/search/SearchWebPart.manifest.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://dev.office.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
+ "id": "42ad2740-3c60-49cf-971a-c44e33511b93",
+ "alias": "SearchWebPart",
+ "componentType": "WebPart",
+
+ // The "*" signifies that the version should be taken from the package.json
+ "version": "*",
+ "manifestVersion": 2,
+
+ // If true, the component can only be installed on sites where Custom Script is allowed.
+ // Components that allow authors to embed arbitrary script code should set this to true.
+ // https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
+ "requiresCustomScript": false,
+
+ "preconfiguredEntries": [{
+ "groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
+ "group": { "default": "PnP" },
+ "title": { "default": "Search with Refiners" },
+ "description": { "default": "Displays search results with customizable dynamic refiners" },
+ "officeFabricIconFontName": "Search",
+ "properties": {
+ "searchQuery": "",
+ "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
new file mode 100644
index 000000000..c794fa628
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/search/SearchWebPart.ts
@@ -0,0 +1,152 @@
+import * as React from 'react';
+import * as ReactDom from 'react-dom';
+import { Version } from '@microsoft/sp-core-library';
+import {
+ BaseClientSideWebPart,
+ PropertyPaneSlider,
+ IPropertyPaneConfiguration,
+ PropertyPaneTextField,
+ PropertyPaneToggle
+} from '@microsoft/sp-webpart-base';
+import { Environment, EnvironmentType } from '@microsoft/sp-core-library';
+import * as strings from 'SearchWebPartStrings';
+import SearchContainer from "./components/SearchContainer/SearchContainer";
+import ISearchContainerProps from "./components/SearchContainer/ISearchContainerProps";
+import { ISearchWebPartProps } from './ISearchWebPartProps';
+import ISearchDataProvider from "../dataProviders/ISearchDataProvider";
+import MockSearchDataProvider from "../dataProviders/MockSearchDataProvider";
+import SearchDataProvider from "../dataProviders/SearchDataProvider";
+import * as moment from "moment";
+import { Placeholder, IPlaceholderProps } from "@pnp/spfx-controls-react/lib/Placeholder";
+
+export default class SearchWebPart extends BaseClientSideWebPart {
+
+ private _dataProvider: ISearchDataProvider;
+
+ /**
+ * Override the base onInit() implementation to get the persisted properties to initialize data provider.
+ */
+ protected onInit(): Promise {
+
+ // Init the moment JS library locale globally
+ const currentLocale = this.context.pageContext.cultureInfo.currentCultureName;
+ moment.locale(currentLocale);
+
+ if (Environment.type === EnvironmentType.Local) {
+ this._dataProvider = new MockSearchDataProvider();
+ } else {
+ this._dataProvider = new SearchDataProvider(this.context);
+ }
+
+ return super.onInit();
+ }
+
+ protected get disableReactivePropertyChanges(): boolean {
+ return true;
+ }
+
+ public render(): void {
+
+ this._dataProvider.resultsCount = this.properties.maxResultsCount;
+ this._dataProvider.selectedProperties = this.properties.selectedProperties ?
+ this.properties.selectedProperties.replace(/\s|,+$/g,'').split(",") :[];
+
+ let renderElement = null;
+
+ const searchContainer: React.ReactElement = React.createElement(
+ SearchContainer,
+ {
+ dataProvider: this._dataProvider,
+ searchQuery: this.properties.searchQuery,
+ maxResultsCount: this.properties.maxResultsCount,
+ selectedProperties: this.properties.selectedProperties,
+ refiners: this.properties.refiners,
+ showPaging: this.properties.showPaging,
+ }
+ );
+
+ const placeholder: React.ReactElement = React.createElement(
+ Placeholder,
+ {
+ iconName: strings.PlaceHolderEditLabel,
+ iconText: strings.PlaceHolderIconText,
+ description: strings.PlaceHolderDescription,
+ buttonLabel: strings.PlaceHolderConfigureBtnLabel,
+ onConfigure: this._setupWebPart.bind(this)
+ }
+ );
+
+ renderElement = this.properties.searchQuery ? searchContainer : placeholder;
+
+ ReactDom.render(renderElement, this.domElement);
+
+ }
+
+ protected get dataVersion(): Version {
+ return Version.parse('1.0');
+ }
+
+ protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
+ return {
+ pages: [
+ {
+ groups: [
+ {
+ groupName: strings.SearchSettingsGroupName,
+ groupFields: [
+ PropertyPaneTextField('searchQuery', {
+ label: strings.SearchQueryFieldLabel,
+ value: "Path:{Site}",
+ multiline: true,
+ resizable: true,
+ placeholder: strings.SearchQueryPlaceHolderText,
+ onGetErrorMessage: this._validateEmptyField.bind(this)
+ }),
+ PropertyPaneTextField('selectedProperties', {
+ label: strings.SelectedPropertiesFieldLabel,
+ multiline: true,
+ resizable: true,
+ value: "Title,Path,Created,Filename,ServerRedirectedPreviewURL",
+ }),
+ PropertyPaneTextField('refiners', {
+ label: strings.RefinersFieldLabel,
+ multiline: true,
+ resizable: true,
+ value: "Created"
+ }),
+ PropertyPaneSlider("maxResultsCount", {
+ label: strings.MaxResultsCount,
+ max: 50,
+ min: 1,
+ showValue: true,
+ step: 1,
+ value: 50,
+ }),
+ PropertyPaneToggle("showPaging", {
+ label: strings.ShowPagingLabel,
+ checked: false,
+ }),
+ ]
+ }
+ ]
+ }
+ ]
+ };
+ }
+
+ /**
+ * Opens the Web Part property pane
+ */
+ private _setupWebPart() {
+ this.context.propertyPane.open();
+ }
+
+ private _validateEmptyField(value: string): string {
+
+ if (!value) {
+ return strings.EmptyFieldErrorMessage;
+ }
+
+ return "";
+ }
+}
diff --git a/samples/react-search-refiners/src/webparts/search/components/FilterPanel/FilterPanel.tsx b/samples/react-search-refiners/src/webparts/search/components/FilterPanel/FilterPanel.tsx
new file mode 100644
index 000000000..43cc73e1b
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/search/components/FilterPanel/FilterPanel.tsx
@@ -0,0 +1,288 @@
+import * as React from "react";
+import IFilterPanelProps from "./IFilterPanelProps";
+import IFilterPanelState from "./IFilterPanelState";
+import { PrimaryButton, DefaultButton, IButtonProps } from 'office-ui-fabric-react/lib/Button';
+import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel';
+import { Toggle } from 'office-ui-fabric-react/lib/Toggle';
+import * as strings from "SearchWebPartStrings";
+import { IRefinementResult, IRefinementValue, IRefinementFilter } from "../../../models/ISearchResult";
+import { Link } from 'office-ui-fabric-react/lib/Link';
+import { Label } from 'office-ui-fabric-react/lib/Label';
+import { Text } from "@microsoft/sp-core-library";
+import "../SearchWebPart.scss";
+import * as update from "immutability-helper";
+import {
+ GroupedList,
+ IGroup,
+ IGroupDividerProps
+ } from 'office-ui-fabric-react/lib/components/GroupedList/index';
+import { Scrollbars } from 'react-custom-scrollbars';
+
+export default class FilterPanel extends React.Component {
+
+ 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: [],
+ expandedGroups: [],
+ };
+
+ this._onTogglePanel = this._onTogglePanel.bind(this);
+ this._onClosePanel = this._onClosePanel.bind(this);
+ 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);
+ }
+
+ public render(): React.ReactElement {
+
+ let items: JSX.Element[] = [];
+ let groups: IGroup[] = [];
+
+ // Initialize the Office UI grouped list
+ this._initialFilters.map((filter, i) => {
+
+ groups.push({
+ key: i.toString(),
+ name: filter.FilterName,
+ count: 1,
+ startIndex: i,
+ isDropEnabled: true,
+ isCollapsed: this.state.expandedGroups.indexOf(i) === -1 ? true : false,
+ });
+
+ items.push(
+
+
+ {
+ filter.Values.map((refinementValue: IRefinementValue, j) => {
+
+ // Create a new IRefinementFilter with only the current refinement information
+ const currentRefinement: IRefinementFilter = {
+ FilterName: filter.FilterName,
+ Value: refinementValue,
+ };
+
+ return (
+ {
+ // 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);
+ }} />
+ );
+ })
+ }
+
{
+
+ // 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,
+ });
+
+ props.onToggleCollapse(props.group);
+ }}>
+
+
+
+
+
+
+
{ props.group.name }
+
+
+ );
+ }
+
+ private _onClosePanel() {
+ this.setState({ showPanel: false });
+ }
+
+ private _onTogglePanel() {
+ this.setState({ showPanel: !this.state.showPanel });
+ }
+
+ private _addFilter(filterToAdd: IRefinementFilter): void {
+
+ // Add the filter to the selected filters collection
+ let newFilters = update(this.state.selectedFilters, {$push: [filterToAdd]});
+ this._applyFilters(newFilters);
+ }
+
+ private _removeFilter(filterToRemove: IRefinementFilter): void {
+
+ // Remove the filter from the selected filters collection
+ let newFilters = this.state.selectedFilters.filter((elt) => {
+ return elt.Value.RefinementToken !== filterToRemove.Value.RefinementToken;
+ });
+
+ this._applyFilters(newFilters);
+ }
+
+ private _applyAllfilters(): void {
+
+ let allFilters: IRefinementFilter[] = [];
+
+ this._initialFilters.map((filter) => {
+
+ filter.Values.map((refinementValue: IRefinementValue, index) => {
+ allFilters.push({FilterName: filter.FilterName, Value: refinementValue});
+ });
+ });
+
+ this._applyFilters(allFilters);
+ }
+
+ private _removeAllFilters(): void {
+ this._applyFilters([]);
+ }
+
+ /**
+ * Inner method to effectivly apply the refiners by calling back the parent component
+ * @param selectedFilters The filters to apply
+ */
+ private _applyFilters(selectedFilters: IRefinementFilter[]): void {
+
+ // Save the selected filters
+ this.setState({
+ selectedFilters: selectedFilters,
+ });
+
+ this.props.onUpdateFilters(selectedFilters);
+ }
+
+ /**
+ * Checks if the current filter is present in the list of the selected filters
+ * @param filterToCheck The filter to check
+ */
+ private _isInFilterSelection(filterToCheck: IRefinementFilter): boolean {
+
+ let newFilters = this.state.selectedFilters.filter((filter) => {
+ return filter.Value.RefinementToken === filterToCheck.Value.RefinementToken;
+ });
+
+ return newFilters.length === 0 ? false : true;
+ }
+}
\ No newline at end of file
diff --git a/samples/react-search-refiners/src/webparts/search/components/FilterPanel/IFilterPanelProps.ts b/samples/react-search-refiners/src/webparts/search/components/FilterPanel/IFilterPanelProps.ts
new file mode 100644
index 000000000..35c42a134
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/search/components/FilterPanel/IFilterPanelProps.ts
@@ -0,0 +1,9 @@
+import { IRefinementResult } from "../../../models/ISearchResult";
+import RefinementFilterOperationCallback from "../../../models/RefinementValueOperationCallback";
+
+interface IFilterPanelProps {
+ availableFilters: IRefinementResult[];
+ onUpdateFilters: RefinementFilterOperationCallback;
+}
+
+export default IFilterPanelProps;
\ No newline at end of file
diff --git a/samples/react-search-refiners/src/webparts/search/components/FilterPanel/IFilterPanelState.tsx b/samples/react-search-refiners/src/webparts/search/components/FilterPanel/IFilterPanelState.tsx
new file mode 100644
index 000000000..a943334c9
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/search/components/FilterPanel/IFilterPanelState.tsx
@@ -0,0 +1,9 @@
+import { IRefinementFilter } from "../../../models/ISearchResult";
+
+interface IFilterPanelState {
+ showPanel?: boolean;
+ selectedFilters?: IRefinementFilter[];
+ expandedGroups?: number[];
+}
+
+export default IFilterPanelState;
\ No newline at end of file
diff --git a/samples/react-search-refiners/src/webparts/search/components/Paging/IPagingProps.ts b/samples/react-search-refiners/src/webparts/search/components/Paging/IPagingProps.ts
new file mode 100644
index 000000000..e22a4521c
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/search/components/Paging/IPagingProps.ts
@@ -0,0 +1,10 @@
+export type PageUpdateCallback = (pageNumber: number) => void;
+
+interface IPagingProps {
+ totalItems: number;
+ itemsCountPerPage: number;
+ onPageUpdate: PageUpdateCallback;
+ currentPage: number;
+}
+
+export default IPagingProps;
\ No newline at end of file
diff --git a/samples/react-search-refiners/src/webparts/search/components/Paging/Paging.tsx b/samples/react-search-refiners/src/webparts/search/components/Paging/Paging.tsx
new file mode 100644
index 000000000..5fa650e8f
--- /dev/null
+++ b/samples/react-search-refiners/src/webparts/search/components/Paging/Paging.tsx
@@ -0,0 +1,40 @@
+import * as React from "react";
+import IPagingProps from "./IPagingProps";
+import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
+import Pagination from "react-js-pagination";
+
+export default class Paging extends React.Component {
+
+ constructor(props: IPagingProps) {
+ super(props);
+
+ this._onPageUpdate = this._onPageUpdate.bind(this);
+ }
+
+ public render(): React.ReactElement {
+
+ return(
+