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); + }} /> + ); + }) + } +
+
+ ); + }); + + const renderSelectedFilters: JSX.Element[] = this.state.selectedFilters.map((filter) => { + + return ( + { this._removeFilter(filter); }} + /> + ); + }); + + const renderAvailableFilters = ; + + return ( +
+ + { (this.state.selectedFilters.length > 0) ? + +
+ + { renderSelectedFilters } +
+ : null + } + { + if(this._initialFilters.length > 0) { + return ( + +
+
+ { + checked ? this._applyAllfilters() : this._removeAllFilters(); + }} + checked= { this.state.selectedFilters.length === 0 ? false : true } + /> +
+ { renderAvailableFilters } +
+
+ ); + } else { + return ( +
+ { strings.NoFilterConfiguredLabel } +
+ ); + } + }}> +
+
+ ); + } + + private _onRenderCell(nestingDepth: number, item: any, itemIndex: number) { + return ( +
+
+ { item } +
+
+ ); + } + + private _onRenderHeader(props: IGroupDividerProps): JSX.Element { + return ( + +
{ + + // 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( +
+
+
+
+ ); + } + + private _onPageUpdate(pageNumber: number): void { + this.props.onPageUpdate(pageNumber); + } +} + \ No newline at end of file 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 new file mode 100644 index 000000000..72b2956b8 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerProps.ts @@ -0,0 +1,12 @@ +import ISearchDataProvider from "../../../dataProviders/ISearchDataProvider"; + +interface ISearchContainerProps { + dataProvider: ISearchDataProvider; + searchQuery: string; + maxResultsCount: number; + selectedProperties: string[]; + refiners: string; + showPaging: boolean; +} + +export default ISearchContainerProps; \ No newline at end of file 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 new file mode 100644 index 000000000..4ee8141ab --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/ISearchContainerState.ts @@ -0,0 +1,12 @@ +import { ISearchResults, IRefinementFilter } from "../../../models/ISearchResult"; + +interface ISearchContainerState { + results?: ISearchResults; + selectedFilters?: IRefinementFilter[]; + currentPage?: number; + errorMessage?: string; + hasError?: boolean; + areResultsLoading?: 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 new file mode 100644 index 000000000..8785a817d --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/components/SearchContainer/SearchContainer.tsx @@ -0,0 +1,154 @@ +import * as React from "react"; +import ISearchContainerProps from "./ISearchContainerProps"; +import ISearchContainerState from "./ISearchContainerState"; +import { MessageBar, MessageBarType } from "office-ui-fabric-react/lib/MessageBar"; +import { Spinner, SpinnerSize } from "office-ui-fabric-react/lib/Spinner"; +import { Logger, LogLevel } from "sp-pnp-js"; +import * as strings from "SearchWebPartStrings"; +import { ISearchResults, IRefinementFilter } from "../../../models/ISearchResult"; +import TilesList from "../TilesList/TilesList"; +import "../SearchWebPart.scss"; +import FilterPanel from "../FilterPanel/FilterPanel"; +import Paging from "../Paging/Paging"; + +export default class SearchContainer extends React.Component { + + public constructor(props) { + + super(props); + + // Set the initial state + this.state = { + results: { + RefinementResults: [], + RelevantResults: [] + }, + selectedFilters: [], + currentPage: 1, + areResultsLoading: true, + errorMessage: "", + hasError: false, + }; + + this._onUpdateFilters = this._onUpdateFilters.bind(this); + this._onPageUpdate = this._onPageUpdate.bind(this); + } + + public render(): React.ReactElement { + + const areResultsLoading = this.state.areResultsLoading; + const items = this.state.results; + const hasError = this.state.hasError; + const errorMessage = this.state.errorMessage; + + let wpContent: JSX.Element = null; + + if (areResultsLoading) { + wpContent = ; + } else { + + if (hasError) { + + wpContent = { errorMessage }; + + } else { + + if (items.RelevantResults.length === 0) { + + wpContent = +
+ +
{ strings.NoResultMessage }
+
; + + } else { + + wpContent = + +
+ + + { this.props.showPaging ? + + : null + } +
; + } + } + } + + return ( +
+ { wpContent } +
+ ); + } + + public componentDidMount() { + + // Async calls + this._getSearchResults(this.props.searchQuery, this.props.refiners, this.state.selectedFilters, this.state.currentPage); + } + + public componentWillReceiveProps(nextProps: ISearchContainerProps): void { + + // Intermediate state to display the spinner before an async query + this.setState({ + areResultsLoading: true, + }); + + // We reset the page number and refinement filters + this._getSearchResults(nextProps.searchQuery, nextProps.refiners, [], 1); + } + + private _getSearchResults(searchQuery: string, refiners: string, refinementFilters?: IRefinementFilter[], pageNumber?: number) { + + this.props.dataProvider.search(searchQuery, refiners, refinementFilters, pageNumber).then((searchResults: ISearchResults) => { + + this.setState({ + results: searchResults, + areResultsLoading: false, + }); + + }).catch((error) => { + Logger.write("[SearchContainer._getSearchResults()]: Error: " + error, LogLevel.Error); + + this.setState({ + areResultsLoading: 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); + + this.setState({ + selectedFilters: newFilters, + currentPage: 1, + }); + } + + /** + * Callback function update search results according the page number + * @param pageNumber The page mumber to get + */ + private _onPageUpdate(pageNumber: number) { + this._getSearchResults(this.props.searchQuery, this.props.refiners, this.state.selectedFilters, pageNumber); + + this.setState({ + currentPage: pageNumber, + }); + } +} \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/search/components/SearchWebPart.scss b/samples/react-search-refiners/src/webparts/search/components/SearchWebPart.scss new file mode 100644 index 000000000..291b692f5 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/components/SearchWebPart.scss @@ -0,0 +1,146 @@ +.searchWp { + + &__tile { + float: left; + + &__footer { + padding: 8px 16px; + margin-top: 15px; + } + + &__footerInfoRight { + position: absolute; + right: 0; + bottom: 0; + padding: 8px 16px; + color: #ffffff; + background-color: #0078d7; + + i { + position: relative; + top: 2px; + } + + span { + padding: 5px; + } + } + + &__iconContainer { + + text-align: center; + + i { + position: relative; + font-size: 40px; + } + } + } + + &__resultCard { + margin: 10px; + } + + &__list { + overflow: hidden; + } + + &__noresult { + padding:10px; + text-align: center; + } + + iframe { + border: 0; + width: 100%; + } + + &__filterResultBtn, &__selectedFilterLbl { + margin: 10px; + } + + &__selectedFilters { + + &__filterBtn { + border-radius: 10px; + margin-bottom: 10px; + margin-left: 10px; + } + } + + &__paginationContainer { + text-align: center; + margin-top: 15px; + + .searchWp__paginationContainer__pagination { + display: inline-block; + text-align: center; + + ul { + display: inline-block; + padding-left: 0; + margin: 20px 0; + border-radius: 4px; + + li { + display: inline; + + a { + float: left; + padding: 5px 10px; + text-decoration: none; + border-radius: 15px; + + i { + font-size: 10px; + } + } + + a:visited { + color: inherit; + } + + a.active { + background-color: #0078d7; + color: white; + } + } + } + } + } +} + +.filterPanel { + + &__body { + padding: 20px; + overflow: auto; + + .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; + } + } + } + + &__allFiltersToggle { + margin-bottom: 25px; + } + + &__group { + + .ms-List-page~.ms-List-page { + margin-top: 15px; + } + } + } +} \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/search/components/TilesList/ITileProps.ts b/samples/react-search-refiners/src/webparts/search/components/TilesList/ITileProps.ts new file mode 100644 index 000000000..efd94e235 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/components/TilesList/ITileProps.ts @@ -0,0 +1,7 @@ +import { ISearchResult } from "../../../models/ISearchResult"; + +interface ITileProps { + item: ISearchResult; +} + +export default ITileProps; \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/search/components/TilesList/ITilesListViewProps.ts b/samples/react-search-refiners/src/webparts/search/components/TilesList/ITilesListViewProps.ts new file mode 100644 index 000000000..0750622f0 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/components/TilesList/ITilesListViewProps.ts @@ -0,0 +1,7 @@ +import { ISearchResult } from "../../../models/ISearchResult"; + +interface ITilesListViewProps { + items?: ISearchResult[]; +} + +export default ITilesListViewProps; \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/search/components/TilesList/Tile.tsx b/samples/react-search-refiners/src/webparts/search/components/TilesList/Tile.tsx new file mode 100644 index 000000000..a0dfea8f5 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/components/TilesList/Tile.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import ITileProps from "./ITileProps"; +import { + DocumentCard, + DocumentCardActions, + DocumentCardActivity, + DocumentCardLocation, + DocumentCardPreview, + DocumentCardTitle, + IDocumentCardPreviewProps +} from 'office-ui-fabric-react/lib/DocumentCard'; +import { ImageFit } from 'office-ui-fabric-react/lib/Image'; +import * as moment from "moment"; +import "../SearchWebPart.scss"; + +const PREVIEW_IMAGE_WIDTH: number = 204; +const PREVIEW_IMAGE_HEIGHT: number = 111; + +export default class Tile extends React.Component { + + public render() { + + const item = this.props.item; + + let previewProps: IDocumentCardPreviewProps = { + previewImages: [ + { + url: item.ServerRedirectedURL ? item.ServerRedirectedURL : item.Path, + previewImageSrc: item.ServerRedirectedPreviewURL, + iconSrc: item.iconSrc, + imageFit: ImageFit.cover, + height: PREVIEW_IMAGE_HEIGHT, + } + ], + }; + + return ( + +
+ +
+ +
+ { moment(item.Created).isValid() ? moment(item.Created).format("L"): null } +
+
+ ); + } +} diff --git a/samples/react-search-refiners/src/webparts/search/components/TilesList/TilesList.tsx b/samples/react-search-refiners/src/webparts/search/components/TilesList/TilesList.tsx new file mode 100644 index 000000000..612f1cf4c --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/components/TilesList/TilesList.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import ITilesListViewProps from "./ITilesListViewProps"; +import { List } from 'office-ui-fabric-react/lib/List'; +import Tile from "./Tile"; +import { IRectangle } from "office-ui-fabric-react/lib/Utilities"; +import "../SearchWebPart.scss"; + +const ROWS_PER_PAGE = 3; +const MAX_ROW_HEIGHT = 300; + +export default class TilesList extends React.Component { + + private _positions: any; + private _columnCount: number; + private _columnWidth: number; + private _rowHeight: number; + + constructor() { + super(); + + this._positions = {}; + this._getItemCountForPage = this._getItemCountForPage.bind(this); + this._getPageHeight = this._getPageHeight.bind(this); + } + + public render() { + + const items = this.props.items; + + return ( + ( +
+ +
+ )}/> + ); + } + + private _getItemCountForPage(itemIndex: number, surfaceRect: IRectangle) { + if (itemIndex === 0) { + this._columnCount = Math.ceil(surfaceRect.width / MAX_ROW_HEIGHT); + this._columnWidth = Math.floor(surfaceRect.width / this._columnCount); + this._rowHeight = this._columnWidth; + } + + return this._columnCount * ROWS_PER_PAGE; + } + + private _getPageHeight(itemIndex: number, surfaceRect: IRectangle) { + return this._rowHeight * ROWS_PER_PAGE; + } +} \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/search/loc/en-us.js b/samples/react-search-refiners/src/webparts/search/loc/en-us.js new file mode 100644 index 000000000..814842ea7 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/loc/en-us.js @@ -0,0 +1,24 @@ +define([], function() { + return { + "SearchSettingsGroupName": "Search settings", + "SearchQueryFieldLabel": "Search query", + "SelectedPropertiesFieldLabel": "Selected Properties", + "LoadingMessage": "Results are loading, please wait...", + "MaxResultsCount": "Number of items to retrieve per page", + "NoResultMessage": "There is no items to show", + "RefinersFieldLabel": "Refiners", + "FilterPanelTitle": "Available filters", + "FilterResultsButtonLabel": "Filter results", + "SelectedFiltersLabel": "Selected filters:", + "ApplyAllFiltersLabel": "Apply all filters", + "RemoveAllFiltersLabel": "Remove all filters", + "ShowPagingLabel": "Show paging", + "NoFilterConfiguredLabel": "No filter configured", + "SearchQueryPlaceHolderText": "Search query in KQL format", + "EmptyFieldErrorMessage": "This field cannot be empty", + "PlaceHolderEditLabel": "Edit", + "PlaceHolderConfigureBtnLabel": "Configure", + "PlaceHolderIconText": "Search Results Web Part with Refinements", + "PlaceHolderDescription": "This component displays search results with paging and customizable refinement panel" + } +}); \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/search/loc/fr-fr.js b/samples/react-search-refiners/src/webparts/search/loc/fr-fr.js new file mode 100644 index 000000000..41605fd62 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/loc/fr-fr.js @@ -0,0 +1,24 @@ +define([], function() { + return { + "SearchSettingsGroupName": "Paramètres de recherche", + "SearchQueryFieldLabel": "Requête de recherche", + "SelectedPropertiesFieldLabel": "Propriétés à récupérer", + "LoadingMessage": "Les résultats sont en cours de chargement, veuillez patienter...", + "MaxResultsCount": "Nombre de résulats à récupérer par page", + "NoResultMessage": "Il n'y a aucun élément à afficher.", + "RefinersFieldLabel": "Filtres", + "FilterPanelTitle": "Filtres disponibles", + "FilterResultsButtonLabel": "Filtrer l'affichage", + "SelectedFiltersLabel": "Filtre(s) appliqué(s):", + "ApplyAllFiltersLabel": "Appliquer tous les filters", + "RemoveAllFiltersLabel": "Supprimer tous les filtres", + "ShowPagingLabel": "Afficher la pagination", + "NoFilterConfiguredLabel": "Aucun filtre configuré", + "SearchQueryPlaceHolderText": "Requête de recherche au format KQL", + "EmptyFieldErrorMessage": "Ce champ ne peut pas être vide", + "PlaceHolderEditLabel": "Éditer", + "PlaceHolderConfigureBtnLabel": "Configurer", + "PlaceHolderIconText": "Web Part de recherche avec affinements", + "PlaceHolderDescription": "Ce composant affiche une liste de résulats de recherche avec pagination et des filtres configurables" + } +}); \ No newline at end of file diff --git a/samples/react-search-refiners/src/webparts/search/loc/mystrings.d.ts b/samples/react-search-refiners/src/webparts/search/loc/mystrings.d.ts new file mode 100644 index 000000000..03d1ecae3 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/loc/mystrings.d.ts @@ -0,0 +1,27 @@ +declare interface ISearchWebPartStrings { + SearchSettingsGroupName: string; + SearchQueryFieldLabel: string; + SelectedPropertiesFieldLabel: string; + LoadingMessage: string; + MaxResultsCount: string; + NoResultMessage: string; + RefinersFieldLabel: string; + FilterPanelTitle: string; + FilterResultsButtonLabel: string; + SelectedFiltersLabel: string; + ApplyAllFiltersLabel: string; + RemoveAllFiltersLabel: string; + ShowPagingLabel: string; + NoFilterConfiguredLabel: string; + SearchQueryPlaceHolderText: string; + EmptyFieldErrorMessage: string; + PlaceHolderEditLabel: string; + PlaceHolderConfigureBtnLabel: string; + PlaceHolderIconText: string; + PlaceHolderDescription: string; +} + +declare module 'SearchWebPartStrings' { + const strings: ISearchWebPartStrings; + export = strings; +} diff --git a/samples/react-search-refiners/src/webparts/search/test/SearchWebPart.test.ts b/samples/react-search-refiners/src/webparts/search/test/SearchWebPart.test.ts new file mode 100644 index 000000000..7948181b4 --- /dev/null +++ b/samples/react-search-refiners/src/webparts/search/test/SearchWebPart.test.ts @@ -0,0 +1,9 @@ +/// + +import { assert } from 'chai'; + +describe('SearchWebPart', () => { + it('should do something', () => { + assert.ok(true); + }); +}); diff --git a/samples/react-search-refiners/tsconfig.json b/samples/react-search-refiners/tsconfig.json new file mode 100644 index 000000000..20a531bae --- /dev/null +++ b/samples/react-search-refiners/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es5", + "forceConsistentCasingInFileNames": true, + "module": "commonjs", + "jsx": "react", + "declaration": true, + "sourceMap": true, + "experimentalDecorators": true, + "types": [ + "es6-promise", + "es6-collections", + "webpack-env" + ] + } +} diff --git a/samples/react-search-refiners/typings/@ms/odsp.d.ts b/samples/react-search-refiners/typings/@ms/odsp.d.ts new file mode 100644 index 000000000..5a2404000 --- /dev/null +++ b/samples/react-search-refiners/typings/@ms/odsp.d.ts @@ -0,0 +1,11 @@ +// Type definitions for Microsoft ODSP projects +// Project: ODSP + +/* Global definition for UNIT_TEST builds + Code that is wrapped inside an if(UNIT_TEST) {...} + block will not be included in the final bundle when the + --ship flag is specified */ +declare const UNIT_TEST: boolean; + +/* Global defintion for SPO builds */ +declare const DATACENTER: boolean; \ No newline at end of file diff --git a/samples/react-search-refiners/typings/tsd.d.ts b/samples/react-search-refiners/typings/tsd.d.ts new file mode 100644 index 000000000..e7efdd728 --- /dev/null +++ b/samples/react-search-refiners/typings/tsd.d.ts @@ -0,0 +1 @@ +///