mirror of
https://github.com/pnp/sp-dev-fx-webparts.git
synced 2025-03-02 01:39:21 +00:00
[react-search-refiners] Added query suggestions + send query to new page features (#508)
* [react-search-refiners] * Upgraded to SPFx 1.4.1 * Added the ability to set you own refiners labels in the filters panel. * Replaced the `pushState` method by the SPFx `eventAggregator` for the communication between the search box and results web parts. * CSS improvements * Added an option to show the results count * [react-search-refiners] * Added the query suggestions feature to the search box * Added the ability to send the query to an other page * Added a result count option * [react-search-refiners] Update images * [react-search-refiners] Updated version in the README file.
This commit is contained in:
parent
315561012b
commit
30f2baa665
@ -1,7 +1,7 @@
|
||||
# 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.
|
||||
This sample shows you how to build user friendly SharePoint search experiences using Office UI fabric tiles, custom refiners, paging and suggestions.
|
||||
|
||||
<p align="center">
|
||||
<img src="./images/react-search-refiners.gif"/>
|
||||
@ -30,6 +30,8 @@ Version|Date|Comments
|
||||
1.0 | October 17, 2017 | Initial release
|
||||
1.1 | January 03, 2018 | Improvements and updating to SPFx drop 1.4
|
||||
1.2 | February 12, 2018 | Added a search box Web Part + Added a "Result Source Id" and "Enable Query Rules" parameters.
|
||||
1.3 | April 1, 2018 | Added the result count + entered keywords option
|
||||
1.4 | May 10, 2018 | <ul><li>Added the query suggestions feature to the search box Web Part</li><li>Added the automatic translation for taxonomy filter values according to the current site locale.</li> <li>Added the option in the search box Web Part to send the query to an other page</ul>
|
||||
|
||||
## 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.**
|
||||
@ -47,29 +49,39 @@ Version|Date|Comments
|
||||
|
||||
The following settings are available in the Web Part property pane:
|
||||
|
||||
<p align="center">
|
||||
<img src="./images/property_pane.png"/>
|
||||
</p>
|
||||
<p align="center"><img src="./images/property_pane.png"/><p>
|
||||
|
||||
Setting | Description
|
||||
-------|----
|
||||
Search query keywords | The search query in KQL format. You can use search query variables (See this [post](http://www.techmikael.com/2015/07/sharepoint-rest-do-support-query.html) to know which ones are allowed).
|
||||
Query template | The search query template in KQL format. You can use search variables here (like Path:{Site}).
|
||||
Selected properties | The search managed properties to retrieve. You can use these proeprties then in the code like this (`item.property_name`). (See the *Tile.tsx* file) .
|
||||
Result Source Identifier | The GUID of a SharePoint result source. If you specify a value here, query template and query keywords won't be applied. Otherwise the default SharePoint result source is used.
|
||||
Enable Query Rules | Enable the query rules if applies
|
||||
Selected properties | The search managed properties to retrieve. You can use these properties 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. You can also provide your own custom labels using the following format RefinableString01:"You custom filter label",RefinableString02:"You custom filter label",...
|
||||
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 blank if no result | Shows nothing if there is no result
|
||||
Show result count | Shows the result count and entered keywords
|
||||
Show paging | Indicates whether or not the component should show the paging control at the bottom.
|
||||
Show file icons | Shows the file icon for individual result
|
||||
Show created date | Shows the created date for individual result
|
||||
|
||||
## Search Box/Search Results communication
|
||||
### Taxonomy values dynamic translation
|
||||
|
||||
The communication between the two web parts is done using the default SPFx `eventAggregator` property (still in alpha as of march 2018). However, this link can be updated to use the concept shown in the [react-rxjs-event-emitter](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-rxjs-event-emitter) example().
|
||||
This Web Part supports the translation for taxonomy based filters according to current site language. To get it work, you must map a new refinable managed property associated with *ows_taxId_<your_column_name>* crawled property.
|
||||
|
||||
Checkout this [article](https://blog.velingeorgiev.com/sharepoint-framework-publish-subscribe-event-messaging) by Velin Georgiev to get more information.
|
||||
<p align="center">
|
||||
<img src="./images/managed-property.png"/>
|
||||
</p>
|
||||
|
||||
### Query suggestions
|
||||
|
||||
Refer to the following [article](https://docs.microsoft.com/en-us/sharepoint/search/manage-query-suggestions) to know how to add query suggestions in SharePoint (caution: it can take up to 24h for changes to take effect).
|
||||
|
||||
## 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.
|
||||
- Build an user friendly search experience on the top of the SharePoint search REST API with paging, refiners and query suggestions using the *@pnp* JavaScript 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).
|
||||
|
@ -19,7 +19,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"externals": {},
|
||||
"externals": {
|
||||
},
|
||||
"localizedResources": {
|
||||
"SearchWebPartStrings": "lib/webparts/searchResults/loc/{locale}.js",
|
||||
"PropertyControlStrings": "./node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js",
|
||||
|
@ -3,8 +3,9 @@
|
||||
"solution": {
|
||||
"name": "PnP - Search Web Parts",
|
||||
"id": "890affef-33e0-4d72-bd72-36399e02143b",
|
||||
"version": "1.1.0.2",
|
||||
"includeClientSideAssets": true
|
||||
"version": "2.0.0.0",
|
||||
"includeClientSideAssets": true,
|
||||
"skipFeatureDeployment": true
|
||||
},
|
||||
"paths": {
|
||||
"zippedPackage": "solution/pnp-react-search-refiners.sppkg"
|
||||
|
BIN
samples/react-search-refiners/images/managed-property.png
Normal file
BIN
samples/react-search-refiners/images/managed-property.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 56 KiB |
Binary file not shown.
Before Width: | Height: | Size: 11 MiB After Width: | Height: | Size: 12 MiB |
2881
samples/react-search-refiners/package-lock.json
generated
2881
samples/react-search-refiners/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,16 +15,18 @@
|
||||
"@microsoft/sp-lodash-subset": "~1.4.1",
|
||||
"@microsoft/sp-office-ui-fabric-core": "~1.4.1",
|
||||
"@microsoft/sp-webpart-base": "~1.4.1",
|
||||
"@pnp/common": "1.0.3",
|
||||
"@pnp/logging": "1.0.3",
|
||||
"@pnp/odata": "1.0.3",
|
||||
"@pnp/sp": "1.0.3",
|
||||
"@pnp/spfx-controls-react": "1.2.3",
|
||||
"@pnp/spfx-property-controls": "1.4.2",
|
||||
"@pnp/common": "1.0.4",
|
||||
"@pnp/logging": "1.0.4",
|
||||
"@pnp/odata": "1.0.4",
|
||||
"@pnp/sp": "1.0.4",
|
||||
"@pnp/spfx-controls-react": "1.3.0",
|
||||
"@pnp/spfx-property-controls": "1.6.0",
|
||||
"@types/fabric": "^1.5.34",
|
||||
"@types/react": "15.6.6",
|
||||
"@types/react-dom": "15.5.6",
|
||||
"@types/sharepoint": "2013.1.9",
|
||||
"@types/webpack-env": ">=1.12.1 <1.14.0",
|
||||
"downshift": "^1.31.14",
|
||||
"immutability-helper": "2.4.0",
|
||||
"lodash-es": "4.17.4",
|
||||
"moment": "2.21.0",
|
||||
@ -32,7 +34,8 @@
|
||||
"react": "15.6.2",
|
||||
"react-custom-scrollbars": "4.1.2",
|
||||
"react-dom": "15.6.2",
|
||||
"react-js-pagination": "3.0.0"
|
||||
"react-js-pagination": "3.0.0",
|
||||
"typescript": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/sp-build-web": "~1.4.1",
|
||||
|
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* This class help you to translate the current culture name into LCID and vice versa
|
||||
* Useful for TaxonomyProvider or other data providers requiring lcid instead culture name.
|
||||
* The class logic is directly inspired from the official SPFx documentation https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/guidance/localize-web-parts
|
||||
*/
|
||||
class LocalizationHelper {
|
||||
|
||||
// Locales to match with this.context.pageContext.cultureInfo.currentUICultureName for SPFx
|
||||
public static locales = {
|
||||
1025: 'ar-SA',
|
||||
1026: 'bg-BG',
|
||||
1027: 'ca-ES',
|
||||
1028: 'zh-TW',
|
||||
1029: 'cs-CZ',
|
||||
1030: 'da-DK',
|
||||
1031: 'de-DE',
|
||||
1032: 'el-GR',
|
||||
1033: 'en-US',
|
||||
1035: 'fi-FI',
|
||||
1036: 'fr-FR',
|
||||
1037: 'he-IL',
|
||||
1038: 'hu-HU',
|
||||
1040: 'it-IT',
|
||||
1041: 'ja-JP',
|
||||
1042: 'ko-KR',
|
||||
1043: 'nl-NL',
|
||||
1044: 'nb-NO',
|
||||
1045: 'pl-PL',
|
||||
1046: 'pt-BR',
|
||||
1048: 'ro-RO',
|
||||
1049: 'ru-RU',
|
||||
1050: 'hr-HR',
|
||||
1051: 'sk-SK',
|
||||
1053: 'sv-SE',
|
||||
1054: 'th-TH',
|
||||
1055: 'tr-TR',
|
||||
1057: 'id-ID',
|
||||
1058: 'uk-UA',
|
||||
1060: 'sl-SI',
|
||||
1061: 'et-EE',
|
||||
1062: 'lv-LV',
|
||||
1063: 'lt-LT',
|
||||
1066: 'vi-VN',
|
||||
1068: 'az-Latn-AZ',
|
||||
1069: 'eu-ES',
|
||||
1071: 'mk-MK',
|
||||
1081: 'hi-IN',
|
||||
1086: 'ms-MY',
|
||||
1087: 'kk-KZ',
|
||||
1106: 'cy-GB',
|
||||
1110: 'gl-ES',
|
||||
1164: 'prs-AF',
|
||||
2052: 'zh-CN',
|
||||
2070: 'pt-PT',
|
||||
2074: 'sr-Latn-CS',
|
||||
2108: 'ga-IE',
|
||||
3082: 'es-ES',
|
||||
5146: 'bs-Latn-BA',
|
||||
9242: 'sr-Latn-RS',
|
||||
10266: 'sr-Cyrl-RS',
|
||||
};
|
||||
|
||||
public static getLocaleId(localeName: string): number {
|
||||
|
||||
const lcid = Object.keys(LocalizationHelper.locales).filter(elt => { return LocalizationHelper.locales[elt] === localeName; });
|
||||
|
||||
if (lcid.length > 0) {
|
||||
return parseInt(lcid[0]);
|
||||
}
|
||||
else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalizationHelper;
|
@ -0,0 +1,72 @@
|
||||
export class UrlHelper {
|
||||
|
||||
/**
|
||||
* Get the value of a querystring
|
||||
* @param {String} field The field to get the value of
|
||||
* @param {String} url The URL to get the value from (optional)
|
||||
* @return {String} The field value
|
||||
*/
|
||||
public static getQueryStringParam(field: string , url: string ) {
|
||||
const href = url ? url : window.location.href;
|
||||
const reg = new RegExp( "[?&#]" + field + "=([^&#]*)", "i" );
|
||||
const qs = reg.exec(href);
|
||||
return qs ? qs[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} field The field name of the query string to remove
|
||||
* @param {String} sourceURL The source URL
|
||||
* @return {String} The updated URL
|
||||
*/
|
||||
public static removeQueryStringParam(field: string , sourceURL: string ) {
|
||||
let rtn = sourceURL.split("?")[0];
|
||||
let param = null;
|
||||
let paramsArr = [];
|
||||
const queryString = (sourceURL.indexOf("?") !== -1) ? sourceURL.split("?")[1] : "";
|
||||
|
||||
if (queryString !== "") {
|
||||
paramsArr = queryString.split("&");
|
||||
for (let i = paramsArr.length - 1; i >= 0; i -= 1) {
|
||||
param = paramsArr[i].split("=")[0];
|
||||
if (param === field) {
|
||||
paramsArr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (paramsArr.length > 0) {
|
||||
rtn = rtn + "?" + paramsArr.join("&");
|
||||
}
|
||||
}
|
||||
return rtn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or replace a query string parameter
|
||||
* @param url The current URL
|
||||
* @param param The query string parameter to add or replace
|
||||
* @param value The new value
|
||||
*/
|
||||
public static addOrReplaceQueryStringParam(url: string, param: string, value: string) {
|
||||
const re = new RegExp("[\\?&]" + param + "=([^&#]*)");
|
||||
const match = re.exec(url);
|
||||
let delimiter;
|
||||
let newString;
|
||||
|
||||
if (match === null) {
|
||||
// Append new param
|
||||
const hasQuestionMark = /\?/.test(url);
|
||||
delimiter = hasQuestionMark ? "&" : "?";
|
||||
newString = url + delimiter + param + "=" + value;
|
||||
} else {
|
||||
delimiter = match[0].charAt(0);
|
||||
newString = url.replace(re, delimiter + param + "=" + value);
|
||||
}
|
||||
|
||||
return newString;
|
||||
}
|
||||
}
|
||||
|
||||
export enum PageOpenBehavior {
|
||||
"Self",
|
||||
"NewTab"
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { ISearchResults, IRefinementFilter } from "../models/ISearchResult";
|
||||
import { IRefinementFilter, ISearchResults } from "../../models/ISearchResult";
|
||||
|
||||
interface ISearchDataProvider {
|
||||
|
||||
@ -28,10 +28,16 @@ interface ISearchDataProvider {
|
||||
enableQueryRules?: boolean;
|
||||
|
||||
/**
|
||||
* Perfoms a search query.
|
||||
* @returns ISearchResults object. Use the "RelevantResults" property to access results properties (returned as key/value pair object => item.[<Managed property name>])
|
||||
* Performs a search query.
|
||||
* @returns ISearchResults object. Use the "RelevantResults" property to acces results proeprties (returned as key/value pair object => item.[<Managed property name>])
|
||||
*/
|
||||
search(kqlQuery: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise<ISearchResults>;
|
||||
|
||||
/**
|
||||
* Retrieves search query suggestions
|
||||
* @param query the term to suggest from
|
||||
*/
|
||||
suggest(query: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export default ISearchDataProvider;
|
@ -1,10 +1,14 @@
|
||||
import ISearchDataProvider from "./ISearchDataProvider";
|
||||
import { ISearchResults, IRefinementFilter, ISearchResult } from "../models/ISearchResult";
|
||||
import { ISearchResults, IRefinementFilter, ISearchResult } from "../../models/ISearchResult";
|
||||
import intersection from "lodash-es/intersection";
|
||||
import clone from "lodash-es/clone";
|
||||
|
||||
class MockSearchDataProvider implements ISearchDataProvider {
|
||||
|
||||
public queryTemplate?: string;
|
||||
public resultSourceId?: string;
|
||||
public enableQueryRules?: boolean;
|
||||
|
||||
public selectedProperties: string[];
|
||||
|
||||
private _itemsCount: number;
|
||||
@ -13,6 +17,7 @@ class MockSearchDataProvider implements ISearchDataProvider {
|
||||
public set resultsCount(value: number) { this._itemsCount = value; }
|
||||
|
||||
private _searchResults: ISearchResults;
|
||||
private _suggestions: string[];
|
||||
|
||||
public constructor() {
|
||||
|
||||
@ -98,6 +103,18 @@ class MockSearchDataProvider implements ISearchDataProvider {
|
||||
],
|
||||
TotalRows: 5,
|
||||
};
|
||||
|
||||
this._suggestions = [
|
||||
"sharepoint",
|
||||
"analysis document",
|
||||
"project document",
|
||||
"office 365",
|
||||
"azure cloud architecture",
|
||||
"architecture document",
|
||||
"sharepoint governance guide",
|
||||
"hr policies",
|
||||
"human resources procedures"
|
||||
];
|
||||
}
|
||||
|
||||
public search(query: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise<ISearchResults> {
|
||||
@ -139,6 +156,34 @@ class MockSearchDataProvider implements ISearchDataProvider {
|
||||
return p1;
|
||||
}
|
||||
|
||||
public async suggest(keywords: string): Promise<string[]> {
|
||||
|
||||
let proposedSuggestions: string[] = [];
|
||||
|
||||
const p1 = new Promise<string[]>((resolve, reject) => {
|
||||
this._suggestions.map(suggestion => {
|
||||
|
||||
const idx = suggestion.toLowerCase().indexOf(keywords.toLowerCase());
|
||||
if (idx !== -1) {
|
||||
|
||||
const preMatchedText = suggestion.substring(0, idx);
|
||||
const postMatchedText = suggestion.substring(idx + keywords.length, suggestion.length);
|
||||
const matchedText = suggestion.substr(idx, keywords.length);
|
||||
|
||||
proposedSuggestions.push(`${preMatchedText}<B>${matchedText}</B>${postMatchedText}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate an async call
|
||||
setTimeout(() => {
|
||||
resolve(proposedSuggestions);
|
||||
}, 100);
|
||||
|
||||
});
|
||||
|
||||
return p1;
|
||||
}
|
||||
|
||||
private _paginate (array, pageSize: number, pageNumber: number) {
|
||||
let basePage = --pageNumber * pageSize;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import ISearchDataProvider from "./ISearchDataProvider";
|
||||
import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter } from "../models/ISearchResult";
|
||||
import { sp, SearchQuery, SearchQueryBuilder, SearchResults, SPRest, Web, Sort, SortDirection } from "@pnp/sp";
|
||||
import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter } from "../../models/ISearchResult";
|
||||
import { sp, SearchQuery, SearchQueryBuilder, SearchResults, SPRest, Web, Sort, SortDirection, SearchSuggestQuery } from "@pnp/sp";
|
||||
import { PnPClientStorage, Util } from "@pnp/common";
|
||||
import { Logger, LogLevel, ConsoleListener } from "@pnp/logging";
|
||||
import { IWebPartContext } from "@microsoft/sp-webpart-base";
|
||||
@ -10,6 +10,7 @@ import groupBy from 'lodash-es/groupBy';
|
||||
import mapValues from 'lodash-es/mapValues';
|
||||
import mapKeys from "lodash-es/mapKeys";
|
||||
import * as moment from "moment";
|
||||
import LocalizationHelper from "../../helpers/LocalizationHelper";
|
||||
|
||||
class SearchDataProvider implements ISearchDataProvider {
|
||||
|
||||
@ -210,6 +211,42 @@ class SearchDataProvider implements ISearchDataProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search query suggestions
|
||||
* @param query the term to suggest from
|
||||
*/
|
||||
public async suggest(query: string): Promise<string[]> {
|
||||
|
||||
let suggestions: string[] = [];
|
||||
|
||||
const searchSuggestQuery: SearchSuggestQuery = {
|
||||
preQuery: true,
|
||||
querytext: query,
|
||||
count: 10,
|
||||
hitHighlighting: true,
|
||||
prefixMatch: true,
|
||||
culture: LocalizationHelper.getLocaleId(this._context.pageContext.cultureInfo.currentUICultureName).toString()
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this._localPnPSetup.searchSuggest(searchSuggestQuery);
|
||||
|
||||
if (response.Queries.length > 0) {
|
||||
|
||||
// Get only the suggesiton string value
|
||||
suggestions = response.Queries.map(elt => {
|
||||
return elt.Query;
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
|
||||
} catch (error) {
|
||||
Logger.write("[SharePointDataProvider.suggest()]: Error: " + error, LogLevel.Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the icon corresponding to the file name extension
|
||||
* @param filename The file name (ex: file.pdf)
|
@ -0,0 +1,18 @@
|
||||
interface ITaxonomyDataProvider {
|
||||
|
||||
/**
|
||||
* Ensure all script dependencies are loaded before using the taxonomy SharePoint CSOM functions
|
||||
* https://dev.office.com/sharepoint/docs/spfx/web-parts/guidance/connect-to-sharepoint-using-jsom
|
||||
* @return {Promise<void>} A promise allowing you to execute your code logic.
|
||||
*/
|
||||
initialize();
|
||||
|
||||
/**
|
||||
* Gets multiple terms by their ids using the current taxonomy context
|
||||
* @param termIds An array of term ids to search for
|
||||
* @return {Promise<SP.Taxonomy.TermCollection>} A promise containing the terms.
|
||||
*/
|
||||
getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection>;
|
||||
}
|
||||
|
||||
export default ITaxonomyDataProvider;
|
@ -0,0 +1,19 @@
|
||||
|
||||
import ITaxonomyDataProvider from "./ITaxonomyDataProvider";
|
||||
|
||||
class MockTaxonomyDataProvider implements ITaxonomyDataProvider {
|
||||
|
||||
public initialize(): Promise<void> {
|
||||
const p1 = new Promise<void>((resolve, reject) => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
return p1;
|
||||
}
|
||||
|
||||
public getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
export default MockTaxonomyDataProvider;
|
@ -0,0 +1,133 @@
|
||||
import { IWebPartContext } from "@microsoft/sp-webpart-base";
|
||||
import { Logger, LogLevel, ConsoleListener } from "@pnp/logging";
|
||||
import { SPComponentLoader } from '@microsoft/sp-loader';
|
||||
import ITaxonomyDataProvider from "./ITaxonomyDataProvider";
|
||||
import { Text } from "@microsoft/sp-core-library";
|
||||
|
||||
class TaxonomyProvider implements ITaxonomyDataProvider {
|
||||
|
||||
private _workingLanguageLcid: number;
|
||||
private _context: IWebPartContext;
|
||||
private _isInitialized: boolean;
|
||||
|
||||
public constructor(webPartContext: IWebPartContext, workingLanguage?: number){
|
||||
this._context = webPartContext;
|
||||
this._isInitialized = false;
|
||||
this._workingLanguageLcid = workingLanguage ? workingLanguage : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all script dependencies are loaded before using the taxonomy SharePoint CSOM functions
|
||||
* https://dev.office.com/sharepoint/docs/spfx/web-parts/guidance/connect-to-sharepoint-using-jsom
|
||||
* @return {Promise<void>} A promise allowing you to execute your code logic.
|
||||
*/
|
||||
public initialize(): Promise<void> {
|
||||
|
||||
const loadScriptPromise = new Promise<void>((resolve) => {
|
||||
|
||||
const siteCollectionUrl = this._context.pageContext.site.absoluteUrl;
|
||||
|
||||
SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/init.js', {
|
||||
globalExportsName: '$_global_init',
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "init.js", error));
|
||||
})
|
||||
.then((): Promise<{}> => {
|
||||
|
||||
return SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/MicrosoftAjax.js', {
|
||||
globalExportsName: 'Sys'
|
||||
});
|
||||
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "MicrosoftAjax.js", error));
|
||||
})
|
||||
.then((): Promise<{}> => {
|
||||
|
||||
// The SP.Runtime.js file is needed in the hosted workbench environment
|
||||
// However, in a production environment, there will be an error message in the console saying the file is loaded twice
|
||||
// This is not a real issue for our purpose so we can keep these lines.
|
||||
return SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/SP.Runtime.js', {
|
||||
globalExportsName: 'SP'
|
||||
});
|
||||
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "SP.Runtime.js", error));
|
||||
})
|
||||
.then((): Promise<{}> => {
|
||||
return SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/SP.js', {
|
||||
globalExportsName: 'SP'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "SP.js", error));
|
||||
})
|
||||
.then((): Promise<{}> => {
|
||||
return SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/SP.taxonomy.js', {
|
||||
globalExportsName: 'SP'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "SP.taxonomy.js", error));
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
// Hack the default method to pass the correct parameters to the server (bug in SP.taxonomy.js)
|
||||
// https://www.stephensaw.me/sharepoint-sp-taxonomy-js-term-getterms-not-working/
|
||||
const getTerms: any = function (g, h, e, d, c, f) {
|
||||
var a = this.get_context(), b;
|
||||
b = new SP.Taxonomy.TermCollection(a, new SP.ObjectPathMethod(a, this.get_path(), "GetTerms", [g, h, e, d, c, f]));
|
||||
return b;
|
||||
};
|
||||
|
||||
SP.Taxonomy.Term.prototype.getTerms = getTerms;
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return loadScriptPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets multiple terms by their ids using the current taxonomy context
|
||||
* @param termIds An array of term ids to search for
|
||||
*/
|
||||
public getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection> {
|
||||
|
||||
if (termIds.length > 0) {
|
||||
|
||||
const spContext = SP.ClientContext.get_current();
|
||||
const taxSession: SP.Taxonomy.TaxonomySession = SP.Taxonomy.TaxonomySession.getTaxonomySession(spContext);
|
||||
const termStore = taxSession.getDefaultSiteCollectionTermStore();
|
||||
|
||||
if (this._workingLanguageLcid) {
|
||||
termStore.set_workingLanguage(this._workingLanguageLcid);
|
||||
}
|
||||
|
||||
// The namespace SP is only available here (because of the init() method)
|
||||
const terms: SP.Taxonomy.TermCollection = termStore.getTermsById(termIds.map(t => new SP.Guid(t)));
|
||||
|
||||
// Additional properties can be loaded here
|
||||
spContext.load(terms, "Include(Id, Name)");
|
||||
|
||||
const p = new Promise<SP.Taxonomy.TermCollection>((resolve, reject) => {
|
||||
|
||||
spContext.executeQueryAsync(() => {
|
||||
resolve(terms);
|
||||
|
||||
}, (sender, args) => {
|
||||
const errorMessage = "[TaxonomyProvider.getTermById()]: Error: " + args.get_message();
|
||||
Logger.write(errorMessage, LogLevel.Error);
|
||||
reject(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TaxonomyProvider;
|
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* This class help you to translate the current culture name into LCID and vice versa
|
||||
* Useful for TaxonomyProvider or other data providers requiring lcid instead culture name.
|
||||
* The class logic is directly inspired from the official SPFx documentation https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/guidance/localize-web-parts
|
||||
*/
|
||||
|
||||
class LocalizationHelper {
|
||||
|
||||
// Locales to match with this.context.pageContext.cultureInfo.currentUICultureName for SPFx
|
||||
public static locales = {
|
||||
1025: 'ar-SA',
|
||||
1026: 'bg-BG',
|
||||
1027: 'ca-ES',
|
||||
1028: 'zh-TW',
|
||||
1029: 'cs-CZ',
|
||||
1030: 'da-DK',
|
||||
1031: 'de-DE',
|
||||
1032: 'el-GR',
|
||||
1033: 'en-US',
|
||||
1035: 'fi-FI',
|
||||
1036: 'fr-FR',
|
||||
1037: 'he-IL',
|
||||
1038: 'hu-HU',
|
||||
1040: 'it-IT',
|
||||
1041: 'ja-JP',
|
||||
1042: 'ko-KR',
|
||||
1043: 'nl-NL',
|
||||
1044: 'nb-NO',
|
||||
1045: 'pl-PL',
|
||||
1046: 'pt-BR',
|
||||
1048: 'ro-RO',
|
||||
1049: 'ru-RU',
|
||||
1050: 'hr-HR',
|
||||
1051: 'sk-SK',
|
||||
1053: 'sv-SE',
|
||||
1054: 'th-TH',
|
||||
1055: 'tr-TR',
|
||||
1057: 'id-ID',
|
||||
1058: 'uk-UA',
|
||||
1060: 'sl-SI',
|
||||
1061: 'et-EE',
|
||||
1062: 'lv-LV',
|
||||
1063: 'lt-LT',
|
||||
1066: 'vi-VN',
|
||||
1068: 'az-Latn-AZ',
|
||||
1069: 'eu-ES',
|
||||
1071: 'mk-MK',
|
||||
1081: 'hi-IN',
|
||||
1086: 'ms-MY',
|
||||
1087: 'kk-KZ',
|
||||
1106: 'cy-GB',
|
||||
1110: 'gl-ES',
|
||||
1164: 'prs-AF',
|
||||
2052: 'zh-CN',
|
||||
2070: 'pt-PT',
|
||||
2074: 'sr-Latn-CS',
|
||||
2108: 'ga-IE',
|
||||
3082: 'es-ES',
|
||||
5146: 'bs-Latn-BA',
|
||||
9242: 'sr-Latn-RS',
|
||||
10266: 'sr-Cyrl-RS',
|
||||
};
|
||||
|
||||
public static getLocaleId(localeName: string): number {
|
||||
|
||||
const lcid = Object.keys(LocalizationHelper.locales).filter(elt => { return LocalizationHelper.locales[elt] === localeName; });
|
||||
|
||||
if (lcid.length > 0) {
|
||||
return parseInt(lcid[0]);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalizationHelper;
|
@ -0,0 +1,21 @@
|
||||
.searchBox {
|
||||
|
||||
.errorMessage {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.suggestionItem {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.suggestionPanel {
|
||||
border-bottom: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
border-right: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: #ffffff;
|
||||
background-color: '[theme:themeDarker, default:#0078d7]';
|
||||
}
|
||||
}
|
@ -1,25 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import { Version, Environment, EnvironmentType } from '@microsoft/sp-core-library';
|
||||
import {
|
||||
BaseClientSideWebPart,
|
||||
IPropertyPaneConfiguration,
|
||||
PropertyPaneTextField
|
||||
PropertyPaneTextField,
|
||||
PropertyPaneCheckbox,
|
||||
PropertyPaneDropdown,
|
||||
PropertyPaneLabel,
|
||||
PropertyPaneToggle,
|
||||
} from '@microsoft/sp-webpart-base';
|
||||
|
||||
import * as strings from 'SearchBoxWebPartStrings';
|
||||
import SearchBox from './components/SearchBoxContainer';
|
||||
import { ISearchBoxProps } from './components/ISearchBoxContainerProps';
|
||||
import { PageOpenBehavior } from '../common/UrlHelper';
|
||||
import ISearchDataProvider from '../dataProviders/SearchDataProvider/ISearchDataProvider';
|
||||
import MockSearchDataProvider from '../dataProviders/SearchDataProvider/MockSearchDataProvider';
|
||||
import SearchDataProvider from '../dataProviders/SearchDataProvider/SearchDataProvider';
|
||||
|
||||
export interface ISearchBoxWebPartProps {
|
||||
|
||||
/**
|
||||
* Indicates if we should show the query suggestions when typing
|
||||
*/
|
||||
enableQuerySuggestions: boolean;
|
||||
|
||||
/**
|
||||
* Indicates if we should send the query to a new page
|
||||
*/
|
||||
searchInNewPage: boolean;
|
||||
|
||||
/**
|
||||
* The page URL where to send the query
|
||||
*/
|
||||
pageUrl: string;
|
||||
|
||||
/**
|
||||
* Defines the opening behavior for new page
|
||||
*/
|
||||
openBehavior: PageOpenBehavior;
|
||||
}
|
||||
|
||||
export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWebPartProps> {
|
||||
|
||||
private _searchDataProvider: ISearchDataProvider;
|
||||
|
||||
/**
|
||||
* Override the base onInit() implementation to get the persisted properties to initialize data provider.
|
||||
*/
|
||||
protected onInit(): Promise<void> {
|
||||
|
||||
// Initializes data provider on first load according to property pane configuration
|
||||
this.initSearchDataProvider();
|
||||
|
||||
return super.onInit();
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
const element: React.ReactElement<ISearchBoxProps > = React.createElement(
|
||||
SearchBox, {
|
||||
enableQuerySuggestions: this.properties.enableQuerySuggestions,
|
||||
searchDataProvider: this._searchDataProvider,
|
||||
eventAggregator: this.context.eventAggregator,
|
||||
searchInNewPage: this.properties.searchInNewPage,
|
||||
pageUrl: this.properties.pageUrl,
|
||||
openBehavior: this.properties.openBehavior
|
||||
});
|
||||
|
||||
ReactDom.render(element, this.domElement);
|
||||
@ -29,13 +74,78 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
|
||||
return Version.parse('1.0');
|
||||
}
|
||||
|
||||
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void {
|
||||
|
||||
// Initializes data provider on first load according to property pane configuration
|
||||
this.initSearchDataProvider();
|
||||
}
|
||||
|
||||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
groupName: strings.SearchBoxNewPage,
|
||||
groupFields: [
|
||||
PropertyPaneCheckbox("searchInNewPage", {
|
||||
text: strings.SearchBoxSearchInNewPageLabel
|
||||
}),
|
||||
PropertyPaneLabel("", {
|
||||
text: strings.SearchBoxSearchInNewPageDescription
|
||||
}),
|
||||
PropertyPaneTextField("pageUrl", {
|
||||
disabled: !this.properties.searchInNewPage,
|
||||
label: strings.SearchBoxPageUrlLabel,
|
||||
onGetErrorMessage: this._validateUrl.bind(this)
|
||||
}),
|
||||
PropertyPaneDropdown("openBehavior", {
|
||||
label: strings.SearchBoxPageOpenBehaviorLabel,
|
||||
options: [
|
||||
{ key: PageOpenBehavior.Self, text: strings.SearchBoxSameTabOpenBehavior, index: 0 },
|
||||
{ key: PageOpenBehavior.NewTab, text: strings.SearchBoxNewTabOpenBehavior, index: 1 }
|
||||
],
|
||||
disabled: !this.properties.searchInNewPage,
|
||||
selectedKey: 0
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
groupName: strings.SearchBoxQuerySuggestionsSettings,
|
||||
groupFields: [
|
||||
PropertyPaneToggle("enableQuerySuggestions", {
|
||||
checked: false,
|
||||
label: strings.SearchBoxEnableQuerySuggestions
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the query optimization data provider instance according to the current environnement
|
||||
*/
|
||||
private initSearchDataProvider() {
|
||||
|
||||
if (this.properties.enableQuerySuggestions) {
|
||||
if (Environment.type === EnvironmentType.Local ) {
|
||||
this._searchDataProvider = new MockSearchDataProvider();
|
||||
} else {
|
||||
this._searchDataProvider = new SearchDataProvider(this.context);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _validateUrl(value: string) {
|
||||
|
||||
if ((!/^(https?):\/\/[^\s/$.?#].[^\s]*/.test(value) || !value) && this.properties.searchInNewPage) {
|
||||
return strings.SearchBoxUrlErrorMessage;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,12 @@
|
||||
import { IEventAggregator } from "@microsoft/sp-webpart-base";
|
||||
import { PageOpenBehavior } from "../../common/UrlHelper";
|
||||
import ISearchDataProvider from "../../dataProviders/SearchDataProvider/ISearchDataProvider";
|
||||
|
||||
export interface ISearchBoxProps {
|
||||
eventAggregator: IEventAggregator;
|
||||
enableQuerySuggestions: boolean;
|
||||
searchDataProvider: ISearchDataProvider;
|
||||
searchInNewPage: boolean;
|
||||
pageUrl: string;
|
||||
openBehavior: PageOpenBehavior;
|
||||
}
|
||||
|
@ -0,0 +1,32 @@
|
||||
export interface ISearchBoxContainerState {
|
||||
|
||||
/**
|
||||
* List of proposed suggestions in the dropdown list
|
||||
*/
|
||||
proposedQuerySuggestions: string[];
|
||||
|
||||
/**
|
||||
* The list of suggestions explicitly selected by the user
|
||||
*/
|
||||
selectedQuerySuggestions: string[];
|
||||
|
||||
/**
|
||||
* The current value of the input string
|
||||
*/
|
||||
searchInputValue: string;
|
||||
|
||||
/**
|
||||
* Term used as basis to get suggestion
|
||||
*/
|
||||
termToSuggestFrom: string;
|
||||
|
||||
/**
|
||||
* Indicates the component is retrieving suggestions
|
||||
*/
|
||||
isRetrievingSuggestions: boolean;
|
||||
|
||||
/**
|
||||
* Error message
|
||||
*/
|
||||
errorMessage: string;
|
||||
}
|
@ -1,39 +1,287 @@
|
||||
import * as React from 'react';
|
||||
import { ISearchBoxProps } from './ISearchBoxContainerProps';
|
||||
import { escape } from '@microsoft/sp-lodash-subset';
|
||||
import { SearchBox } from "office-ui-fabric-react/lib/SearchBox";
|
||||
import { Text } from "@microsoft/sp-core-library";
|
||||
import * as strings from 'SearchBoxWebPartStrings';
|
||||
import Downshift from 'downshift';
|
||||
import { IconType, Label, TextField, Spinner, SpinnerSize, Overlay, MessageBar, MessageBarType } from 'office-ui-fabric-react';
|
||||
import * as React from 'react';
|
||||
import "../SearchBoxWebPart.scss";
|
||||
import { ISearchBoxProps } from './ISearchBoxContainerProps';
|
||||
import { ISearchBoxContainerState } from './ISearchBoxContainerState';
|
||||
import * as update from "immutability-helper";
|
||||
import { UrlHelper, PageOpenBehavior } from '../../common/UrlHelper';
|
||||
|
||||
export default class SearchBoxContainer extends React.Component<ISearchBoxProps, null> {
|
||||
export default class SearchBoxContainer extends React.Component<ISearchBoxProps, ISearchBoxContainerState> {
|
||||
|
||||
private readonly SUGGESTION_CHAR_COUNT_TRIGGER = 3;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
|
||||
this.onSearch = this.onSearch.bind(this);
|
||||
this._onSearch = this._onSearch.bind(this);
|
||||
this._onChange = this._onChange.bind(this);
|
||||
this._onQuerySuggestionSelected = this._onQuerySuggestionSelected.bind(this);
|
||||
|
||||
this.state = {
|
||||
proposedQuerySuggestions: [],
|
||||
selectedQuerySuggestions: [],
|
||||
isRetrievingSuggestions: false,
|
||||
searchInputValue: null,
|
||||
termToSuggestFrom: null,
|
||||
errorMessage: null
|
||||
};
|
||||
}
|
||||
|
||||
public render(): React.ReactElement<ISearchBoxProps> {
|
||||
|
||||
return (
|
||||
<SearchBox
|
||||
onSearch={ this.onSearch }
|
||||
placeholder={ strings.SearchInputPlaceholder }
|
||||
onClear={ () => { this.onSearch("*") }}
|
||||
/>
|
||||
let renderErrorMessage: JSX.Element = null;
|
||||
|
||||
if (this.state.errorMessage) {
|
||||
renderErrorMessage = <MessageBar messageBarType={ MessageBarType.error }
|
||||
dismissButtonAriaLabel='Close'
|
||||
isMultiline={ false }
|
||||
onDismiss={ () => {
|
||||
this.setState({
|
||||
errorMessage: null,
|
||||
});
|
||||
}}
|
||||
className="errorMessage">
|
||||
{ this.state.errorMessage }</MessageBar>;
|
||||
}
|
||||
|
||||
const renderSearchBox = this.props.enableQuerySuggestions ?
|
||||
this.renderSearchBoxWithAutoComplete() :
|
||||
this.renderBasicSearchBox();
|
||||
return (
|
||||
<div className="searchBox">
|
||||
{ renderErrorMessage }
|
||||
{ renderSearchBox }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderSearchBoxWithAutoComplete(): JSX.Element {
|
||||
return <Downshift
|
||||
onSelect={ this._onQuerySuggestionSelected }
|
||||
>
|
||||
{({
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
isOpen,
|
||||
inputValue,
|
||||
selectedItem,
|
||||
highlightedIndex,
|
||||
openMenu,
|
||||
clearItems
|
||||
}) => (
|
||||
<div>
|
||||
<TextField {...getInputProps({
|
||||
placeholder: strings.SearchInputPlaceholder,
|
||||
onKeyDown: event => {
|
||||
|
||||
// Submit search on "Enter"
|
||||
if (event.keyCode === 13 && (!isOpen || (isOpen && highlightedIndex === null))) {
|
||||
this._onSearch(this.state.searchInputValue);
|
||||
}
|
||||
}
|
||||
})}
|
||||
value={ this.state.searchInputValue }
|
||||
autoComplete= {false }
|
||||
onChanged={ (value) => {
|
||||
|
||||
this.setState({
|
||||
searchInputValue: value,
|
||||
});
|
||||
|
||||
if (this.state.selectedQuerySuggestions.length === 0) {
|
||||
clearItems();
|
||||
|
||||
this._onChange(value);
|
||||
openMenu();
|
||||
} else {
|
||||
if (!value) {
|
||||
|
||||
// Reset the selected suggestions if input is empty
|
||||
this.setState({
|
||||
selectedQuerySuggestions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
iconProps={{
|
||||
iconName: 'Search',
|
||||
iconType: IconType.default
|
||||
}}/>
|
||||
{isOpen ?
|
||||
this.renderSuggestions(getItemProps, openMenu, selectedItem, highlightedIndex)
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</Downshift>;
|
||||
}
|
||||
|
||||
public renderBasicSearchBox(): JSX.Element {
|
||||
return <TextField
|
||||
placeholder={ strings.SearchInputPlaceholder }
|
||||
value={ this.state.searchInputValue }
|
||||
onChanged={ (value) => {
|
||||
this.setState({
|
||||
searchInputValue: value,
|
||||
});
|
||||
}}
|
||||
onKeyDown={ (event) => {
|
||||
|
||||
// Submit search on "Enter"
|
||||
if (event.keyCode === 13) {
|
||||
this._onSearch(this.state.searchInputValue);
|
||||
}
|
||||
}}
|
||||
iconProps={{
|
||||
iconName: 'Search',
|
||||
iconType: IconType.default
|
||||
}}/>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler when a user enters new keywords
|
||||
* Renders the suggestions panel below the input control
|
||||
* @param getItemProps downshift getItemProps callback
|
||||
* @param openMenu downshift openMenu callback
|
||||
* @param selectedItem downshift selectedItem callback
|
||||
* @param highlightedIndex downshift highlightedIndex callback
|
||||
*/
|
||||
private renderSuggestions(getItemProps, openMenu, selectedItem, highlightedIndex): JSX.Element {
|
||||
|
||||
let renderSuggestions: JSX.Element = null;
|
||||
let suggestions: JSX.Element[] = null;
|
||||
|
||||
if (this.state.isRetrievingSuggestions && this.state.proposedQuerySuggestions.length === 0) {
|
||||
renderSuggestions = <div className="suggestionPanel">
|
||||
<div {...getItemProps({item: null, disabled: true})}>
|
||||
<div className="suggestionItem">
|
||||
<Spinner size={ SpinnerSize.small }/>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (this.state.proposedQuerySuggestions.length > 0) {
|
||||
|
||||
suggestions = this.state.proposedQuerySuggestions.map((suggestion, index) => {
|
||||
return <div {...getItemProps({item: suggestion})}
|
||||
key={index}
|
||||
style={{
|
||||
fontWeight: selectedItem === suggestion ? 'bold' : 'normal'
|
||||
}}>
|
||||
<Label className={ highlightedIndex === index ? 'suggestionItem selected': 'suggestionItem'}>
|
||||
<div dangerouslySetInnerHTML={{ __html: suggestion }}></div>
|
||||
</Label>
|
||||
</div>;
|
||||
});
|
||||
|
||||
renderSuggestions = <div className="suggestionPanel">
|
||||
{ suggestions }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return renderSuggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler when a user press enter in the search box
|
||||
* @param queryText The query text entered by the user
|
||||
*/
|
||||
public onSearch(queryText: string) {
|
||||
private async _onSearch(queryText: string) {
|
||||
|
||||
|
||||
if (this.props.searchInNewPage) {
|
||||
|
||||
// Send the query to the a new via the query string
|
||||
const url = UrlHelper.addOrReplaceQueryStringParam(this.props.pageUrl, "q", encodeURIComponent(queryText));
|
||||
|
||||
this.props.eventAggregator.raiseEvent("search:newQueryKeywords", {
|
||||
const behavior = this.props.openBehavior === PageOpenBehavior.NewTab ? "_blank" : "_self";
|
||||
window.open(url, behavior);
|
||||
|
||||
} else {
|
||||
// Send the query to components on the page
|
||||
this.props.eventAggregator.raiseEvent("search:newQueryKeywords", {
|
||||
data: queryText,
|
||||
sourceId: "SearchBoxQuery",
|
||||
targetId: "SearchResults"
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler when a user enters new keywords in the search box input
|
||||
* @param inputValue
|
||||
*/
|
||||
private async _onChange(inputValue: string) {
|
||||
|
||||
if (inputValue && this.props.enableQuerySuggestions) {
|
||||
|
||||
if (inputValue.length >= this.SUGGESTION_CHAR_COUNT_TRIGGER) {
|
||||
|
||||
try {
|
||||
|
||||
this.setState({
|
||||
isRetrievingSuggestions: true,
|
||||
errorMessage: null
|
||||
});
|
||||
|
||||
const suggestions = await this.props.searchDataProvider.suggest(inputValue);
|
||||
|
||||
this.setState({
|
||||
proposedQuerySuggestions: suggestions,
|
||||
termToSuggestFrom: inputValue, // The term that was used as basis to get the suggestions from
|
||||
isRetrievingSuggestions: false
|
||||
});
|
||||
|
||||
} catch(error) {
|
||||
|
||||
this.setState({
|
||||
errorMessage: error.message,
|
||||
proposedQuerySuggestions: [],
|
||||
isRetrievingSuggestions: false
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
// Clear suggestions history
|
||||
this.setState({
|
||||
proposedQuerySuggestions: [],
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if (!inputValue) {
|
||||
|
||||
// Clear suggestions history
|
||||
this.setState({
|
||||
proposedQuerySuggestions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler when a suggestion is selected in the dropdown
|
||||
* @param suggestion the suggestion value
|
||||
*/
|
||||
private _onQuerySuggestionSelected(suggestion: string) {
|
||||
|
||||
const termToSuggestFromIndex = this.state.searchInputValue.indexOf(this.state.termToSuggestFrom);
|
||||
let replacedSearchInputvalue = this._replaceAt(this.state.searchInputValue, termToSuggestFromIndex, suggestion);
|
||||
|
||||
// Remove inenr HTML markup if there is
|
||||
replacedSearchInputvalue = replacedSearchInputvalue.replace(/(<B>|<\/B>)/g,"");
|
||||
|
||||
this.setState({
|
||||
searchInputValue: replacedSearchInputvalue,
|
||||
selectedQuerySuggestions: update(this.state.selectedQuerySuggestions, { $push: [suggestion]}),
|
||||
proposedQuerySuggestions:[],
|
||||
});
|
||||
}
|
||||
|
||||
private _replaceAt(string, index, replace) {
|
||||
return string.substring(0, index) + replace;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,15 @@
|
||||
define([], function() {
|
||||
return {
|
||||
"SearchInputPlaceholder": "Enter your search terms..."
|
||||
"SearchInputPlaceholder": "Enter your search terms...",
|
||||
"SearchBoxQuerySuggestionsSettings": "Search query suggestions settings",
|
||||
"SearchBoxEnableQuerySuggestions": "Enable query suggestions",
|
||||
"SearchBoxNewPage": "Search box options",
|
||||
"SearchBoxSearchInNewPageLabel": "Send the query to a new page",
|
||||
"SearchBoxSearchInNewPageDescription": "Set this option to send the query to an existing search page. Otherwise, the query will be sent to search components on this page.",
|
||||
"SearchBoxPageUrlLabel": "Page URL",
|
||||
"SearchBoxUrlErrorMessage": "Please provide a valid URL.",
|
||||
"SearchBoxSameTabOpenBehavior": "Use the current tab",
|
||||
"SearchBoxNewTabOpenBehavior": "Open in a new tab",
|
||||
"SearchBoxPageOpenBehaviorLabel": "Opening behavior"
|
||||
}
|
||||
});
|
@ -1,5 +1,15 @@
|
||||
define([], function() {
|
||||
return {
|
||||
"SearchInputPlaceholder": "Entrez vos termes de recherche..."
|
||||
}
|
||||
});
|
||||
return {
|
||||
"SearchInputPlaceholder": "Entrez vos termes de recherche...",
|
||||
"SearchBoxQuerySuggestionsSettings": "Paramètres de suggestions de recherche",
|
||||
"SearchBoxEnableQuerySuggestions": "Activer les suggestions des recherche",
|
||||
"SearchBoxNewPage": "Options de la boîte de recherche",
|
||||
"SearchBoxSearchInNewPageLabel": "Envoyer la requête sur une nouvelle page",
|
||||
"SearchBoxSearchInNewPageDescription": "Cochez cette cache si vous souhaitez envoyer la requête à une page de recherche déjà existante. Autrement, la requête sera envoyée aux composants de recherche présents sur la page courante.",
|
||||
"SearchBoxPageUrlLabel": "URL de la page",
|
||||
"SearchBoxUrlErrorMessage": "Veuillez spécifier une URL valide",
|
||||
"SearchBoxSameTabOpenBehavior": "Utiliser l'onglet courant",
|
||||
"SearchBoxNewTabOpenBehavior": "Ouvrir dans un nouvel onglet",
|
||||
"SearchBoxPageOpenBehaviorLabel": "Mode d'ouverture de la page"
|
||||
}
|
||||
});
|
@ -1,5 +1,15 @@
|
||||
declare interface ISearchBoxWebPartStrings {
|
||||
SearchInputPlaceholder: string;
|
||||
SearchBoxQuerySuggestionsSettings: string;
|
||||
SearchBoxEnableQuerySuggestions: string;
|
||||
SearchBoxNewPage: string;
|
||||
SearchBoxSearchInNewPageLabel: string;
|
||||
SearchBoxSearchInNewPageDescription: string;
|
||||
SearchBoxPageUrlLabel: string;
|
||||
SearchBoxUrlErrorMessage: string;
|
||||
SearchBoxSameTabOpenBehavior: string;
|
||||
SearchBoxNewTabOpenBehavior: string;
|
||||
SearchBoxPageOpenBehaviorLabel: string;
|
||||
}
|
||||
|
||||
declare module 'SearchBoxWebPartStrings' {
|
||||
|
@ -10,5 +10,6 @@ export interface ISearchResultsWebPartProps {
|
||||
showFileIcon: boolean;
|
||||
showCreatedDate: boolean;
|
||||
showResultsCount: boolean;
|
||||
showBlank: boolean;
|
||||
useSearchBoxQuery: boolean;
|
||||
}
|
||||
|
@ -32,7 +32,9 @@
|
||||
"enableQueryRules": false,
|
||||
"maxResultsCount": 10,
|
||||
"showFileIcon": true,
|
||||
"showCreatedDate": true
|
||||
"showCreatedDate": true,
|
||||
"showBlank": false,
|
||||
"showResultsCount": false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -14,18 +14,24 @@ import * as strings from 'SearchWebPartStrings';
|
||||
import SearchContainer from "./components/SearchResultsContainer/SearchResultsContainer";
|
||||
import ISearchContainerProps from "./components/SearchResultsContainer/ISearchResultsContainerProps";
|
||||
import { ISearchResultsWebPartProps } from './ISearchResultsWebPartProps';
|
||||
import ISearchDataProvider from "../dataProviders/ISearchDataProvider";
|
||||
import MockSearchDataProvider from "../dataProviders/MockSearchDataProvider";
|
||||
import SearchDataProvider from "../dataProviders/SearchDataProvider";
|
||||
import ISearchDataProvider from "../dataProviders/SearchDataProvider/ISearchDataProvider";
|
||||
import MockSearchDataProvider from "../dataProviders/SearchDataProvider/MockSearchDataProvider";
|
||||
import SearchDataProvider from "../dataProviders/SearchDataProvider/SearchDataProvider";
|
||||
import ITaxonomyDataProvider from "../dataProviders/TaxonomyProvider/ITaxonomyDataProvider";
|
||||
import MockTaxonomyDataProvider from "../dataProviders/TaxonomyProvider/MockTaxonomyDataProvider";
|
||||
import TaxonomyProvider from "../dataProviders/TaxonomyProvider/TaxonomyProvider";
|
||||
import * as moment from "moment";
|
||||
import { Placeholder, IPlaceholderProps } from "@pnp/spfx-controls-react/lib/Placeholder";
|
||||
import { PropertyPaneCheckbox } from '@microsoft/sp-webpart-base/lib/propertyPane/propertyPaneFields/propertyPaneCheckBox/PropertyPaneCheckbox';
|
||||
import { PropertyPaneHorizontalRule } from '@microsoft/sp-webpart-base/lib/propertyPane/propertyPaneFields/propertyPaneHorizontalRule/PropertyPaneHorizontalRule';
|
||||
import { UrlUtilities, DisplayMode } from "@microsoft/sp-core-library";
|
||||
import LocalizationHelper from "../common/LocalizationHelper";
|
||||
import { UrlHelper } from '../common/UrlHelper';
|
||||
|
||||
export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsWebPartProps> {
|
||||
|
||||
private _dataProvider: ISearchDataProvider;
|
||||
private _searchDataProvider: ISearchDataProvider;
|
||||
private _taxonomyDataProvider: ITaxonomyDataProvider;
|
||||
private _useResultSource: boolean;
|
||||
|
||||
public constructor() {
|
||||
@ -45,16 +51,35 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
|
||||
moment.locale(currentLocale);
|
||||
|
||||
if (Environment.type === EnvironmentType.Local) {
|
||||
this._dataProvider = new MockSearchDataProvider();
|
||||
this._searchDataProvider = new MockSearchDataProvider();
|
||||
this._taxonomyDataProvider = new MockTaxonomyDataProvider();
|
||||
} else {
|
||||
this._dataProvider = new SearchDataProvider(this.context);
|
||||
|
||||
const lcid = LocalizationHelper.getLocaleId(this.context.pageContext.cultureInfo.currentUICultureName);
|
||||
|
||||
this._searchDataProvider = new SearchDataProvider(this.context);
|
||||
this._taxonomyDataProvider = new TaxonomyProvider(this.context, lcid);
|
||||
}
|
||||
|
||||
this._useResultSource = false;
|
||||
|
||||
// Use the SPFx event aggregator to get the search box query
|
||||
this.context.eventAggregator.subscribeByEventName("search:newQueryKeywords", this.componentId , this.bindSearchQuery);
|
||||
if (this.properties.useSearchBoxQuery) {
|
||||
|
||||
// Check if there is an existing query parameter on loading (only on first load)
|
||||
const queryStringKeywords = UrlHelper.getQueryStringParam("q", window.location.href);
|
||||
|
||||
if (queryStringKeywords) {
|
||||
this.properties.queryKeywords = decodeURIComponent(queryStringKeywords);
|
||||
} else {
|
||||
this.properties.queryKeywords = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Use the SPFx event aggregator to get the search box query keywords
|
||||
// Bind this on initialization since options can be changed in the proeprty pane an this method won't be called again
|
||||
// We don't want subscribe every time this option is changed
|
||||
this.context.eventAggregator.subscribeByEventName("search:newQueryKeywords", this.componentId , this.bindSearchQuery);
|
||||
|
||||
return super.onInit();
|
||||
}
|
||||
|
||||
@ -68,15 +93,16 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
|
||||
let renderElement = null;
|
||||
|
||||
// Configure the provider before the query according to our needs
|
||||
this._dataProvider.resultsCount = this.properties.maxResultsCount;
|
||||
this._dataProvider.queryTemplate = this.properties.queryTemplate;
|
||||
this._dataProvider.resultSourceId = this.properties.resultSourceId;
|
||||
this._dataProvider.enableQueryRules = this.properties.enableQueryRules;
|
||||
this._searchDataProvider.resultsCount = this.properties.maxResultsCount;
|
||||
this._searchDataProvider.queryTemplate = this.properties.queryTemplate;
|
||||
this._searchDataProvider.resultSourceId = this.properties.resultSourceId;
|
||||
this._searchDataProvider.enableQueryRules = this.properties.enableQueryRules;
|
||||
|
||||
const searchContainer: React.ReactElement<ISearchContainerProps> = React.createElement(
|
||||
SearchContainer,
|
||||
{
|
||||
searchDataProvider: this._dataProvider,
|
||||
searchDataProvider: this._searchDataProvider,
|
||||
taxonomyDataProvider: this._taxonomyDataProvider,
|
||||
queryKeywords: this.properties.queryKeywords,
|
||||
maxResultsCount: this.properties.maxResultsCount,
|
||||
resultSourceId: this.properties.resultSourceId,
|
||||
@ -87,6 +113,8 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
|
||||
showFileIcon: this.properties.showFileIcon,
|
||||
showCreatedDate: this.properties.showCreatedDate,
|
||||
showResultsCount: this.properties.showResultsCount,
|
||||
showBlank: this.properties.showBlank,
|
||||
displayMode: this.displayMode
|
||||
} as ISearchContainerProps
|
||||
);
|
||||
|
||||
@ -106,6 +134,14 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
|
||||
ReactDom.render(renderElement, this.domElement);
|
||||
}
|
||||
|
||||
public onPropertyPaneFieldChanged(changedProperty: string) {
|
||||
|
||||
if (changedProperty === "useSearchBoxQuery") {
|
||||
// Reset the value if use search box (property pane)
|
||||
this.properties.queryKeywords = this.properties.useSearchBoxQuery ? "" : this.properties.queryKeywords;
|
||||
}
|
||||
}
|
||||
|
||||
protected get dataVersion(): Version {
|
||||
return Version.parse('1.0');
|
||||
}
|
||||
@ -125,7 +161,7 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
|
||||
PropertyPaneTextField('queryKeywords', {
|
||||
label: strings.SearchQueryKeywordsFieldLabel,
|
||||
description: strings.SearchQueryKeywordsFieldDescription,
|
||||
value: this.properties.queryKeywords,
|
||||
value: this.properties.useSearchBoxQuery ? "" : this.properties.queryKeywords,
|
||||
multiline: true,
|
||||
resizable: true,
|
||||
placeholder: strings.SearchQueryPlaceHolderText,
|
||||
@ -178,9 +214,17 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
|
||||
})
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
groups: [
|
||||
{
|
||||
groupName: strings.StylingSettingsGroupName,
|
||||
groupFields: [
|
||||
PropertyPaneToggle("showBlank", {
|
||||
label: strings.ShowBlankLabel,
|
||||
checked: this.properties.showBlank,
|
||||
}),
|
||||
PropertyPaneToggle("showResultsCount", {
|
||||
label: strings.ShowResultsCountLabel,
|
||||
checked: this.properties.showResultsCount,
|
||||
@ -228,7 +272,7 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
|
||||
|
||||
this.properties.queryKeywords = eventData.data;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,17 +1,78 @@
|
||||
import ISearchDataProvider from "../../../dataProviders/ISearchDataProvider";
|
||||
import ISearchDataProvider from "../../../dataProviders/SearchDataProvider/ISearchDataProvider";
|
||||
import ITaxonomyDataProvider from "../../../dataProviders/TaxonomyProvider/ITaxonomyDataProvider";
|
||||
import { DisplayMode } from "@microsoft/sp-core-library";
|
||||
|
||||
interface ISearchResultsContainerProps {
|
||||
|
||||
/**
|
||||
* The search data provider instance
|
||||
*/
|
||||
searchDataProvider: ISearchDataProvider;
|
||||
|
||||
/**
|
||||
* The taxonomy data provider instance
|
||||
*/
|
||||
taxonomyDataProvider: ITaxonomyDataProvider;
|
||||
|
||||
/**
|
||||
* The search query keywords
|
||||
*/
|
||||
queryKeywords: string;
|
||||
|
||||
/**
|
||||
* Number of results to retrieve
|
||||
*/
|
||||
maxResultsCount: number;
|
||||
|
||||
/**
|
||||
* The SharePoint result source id to target
|
||||
*/
|
||||
resultSourceId: string;
|
||||
|
||||
/**
|
||||
* Enable SharePoint query rules
|
||||
*/
|
||||
enableQueryRules: boolean;
|
||||
|
||||
/**
|
||||
* Properties to retrieve
|
||||
*/
|
||||
selectedProperties: string[];
|
||||
|
||||
/**
|
||||
* The managed properties used as refiners for the query
|
||||
*/
|
||||
refiners: { [key: string]: string };
|
||||
|
||||
/**
|
||||
* Show the paging control
|
||||
*/
|
||||
showPaging: boolean;
|
||||
|
||||
/**
|
||||
* Show the page icon for individual result
|
||||
*/
|
||||
showFileIcon: boolean;
|
||||
|
||||
/**
|
||||
* Show the created date for individual result
|
||||
*/
|
||||
showCreatedDate: boolean;
|
||||
|
||||
/**
|
||||
* Show the result count and entered keywords
|
||||
*/
|
||||
showResultsCount: boolean;
|
||||
|
||||
/**
|
||||
* Show nothing if no result
|
||||
*/
|
||||
showBlank: boolean;
|
||||
|
||||
/**
|
||||
* The current display mode of Web Part
|
||||
*/
|
||||
displayMode: DisplayMode;
|
||||
}
|
||||
|
||||
export default ISearchResultsContainerProps;
|
@ -6,6 +6,11 @@ interface ISearchResultsContainerState {
|
||||
* The current search results to display
|
||||
*/
|
||||
results: ISearchResults;
|
||||
|
||||
/**
|
||||
* Number of results
|
||||
*/
|
||||
resultCount: number;
|
||||
|
||||
/**
|
||||
* Current selected filters to apply to the search query. We need this information during page transition to keep existing filters
|
||||
|
@ -5,14 +5,14 @@ import { MessageBar, MessageBarType } from "office-ui-fabric-react/lib/MessageBa
|
||||
import { Spinner, SpinnerSize } from "office-ui-fabric-react/lib/Spinner";
|
||||
import { Logger, LogLevel } from "@pnp/logging";
|
||||
import * as strings from "SearchWebPartStrings";
|
||||
import { ISearchResults, IRefinementFilter } from "../../../models/ISearchResult";
|
||||
import { ISearchResults, IRefinementFilter, IRefinementValue, IRefinementResult } from "../../../models/ISearchResult";
|
||||
import TilesList from "../TilesList/TilesList";
|
||||
import "../SearchResultsWebPart.scss";
|
||||
import FilterPanel from "../FilterPanel/FilterPanel";
|
||||
import Paging from "../Paging/Paging";
|
||||
import { Overlay } from "office-ui-fabric-react/lib/Overlay";
|
||||
import { Label } from "office-ui-fabric-react";
|
||||
import { Text } from '@microsoft/sp-core-library';
|
||||
import { Text, DisplayMode } from '@microsoft/sp-core-library';
|
||||
|
||||
export default class SearchResultsContainer extends React.Component<ISearchContainerProps, ISearchContainerState> {
|
||||
|
||||
@ -24,6 +24,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
|
||||
RefinementResults: [],
|
||||
RelevantResults: []
|
||||
},
|
||||
resultCount: 0,
|
||||
selectedFilters: [],
|
||||
availableFilters: [],
|
||||
currentPage: 1,
|
||||
@ -54,12 +55,13 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
|
||||
if (!isComponentLoading && areResultsLoading) {
|
||||
renderOverlay = <div>
|
||||
<Overlay isDarkThemed={false} className="overlay">
|
||||
<Spinner size={SpinnerSize.medium} />
|
||||
</Overlay>
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (this.props.showResultsCount && !this.state.areResultsLoading ) {
|
||||
renderCount = <label dangerouslySetInnerHTML={ {__html: Text.format(strings.CountMessage, this.state.results.TotalRows, this.props.queryKeywords) }}></label>;
|
||||
if (this.props.showResultsCount && !this.state.areResultsLoading) {
|
||||
renderCount = <div className="searchWp__count"><label dangerouslySetInnerHTML={ {__html: Text.format(strings.CountMessage, this.state.resultCount , this.props.queryKeywords) }}></label></div>;
|
||||
}
|
||||
|
||||
if (isComponentLoading) {
|
||||
@ -70,19 +72,27 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
|
||||
renderWpContent = <MessageBar messageBarType={MessageBarType.error}>{errorMessage}</MessageBar>;
|
||||
} else {
|
||||
|
||||
if (items.RelevantResults.length === 0) {
|
||||
renderWpContent =
|
||||
<div>
|
||||
<FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={ this.props.refiners } />
|
||||
<div className="searchWp__count">{ renderCount }</div>
|
||||
<div className="searchWp__noresult">{strings.NoResultMessage}</div>
|
||||
</div>;
|
||||
if (items.RelevantResults.length === 0 ) {
|
||||
|
||||
if (!this.props.showBlank) {
|
||||
|
||||
renderWpContent =
|
||||
<div>
|
||||
<FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={ this.props.refiners } />
|
||||
{ renderCount }
|
||||
<div className="searchWp__noresult">{strings.NoResultMessage}</div>
|
||||
</div>;
|
||||
} else {
|
||||
if (this.props.displayMode === DisplayMode.Edit) {
|
||||
renderWpContent = <MessageBar messageBarType={ MessageBarType.info }>{ strings.ShowBlankEditInfoMessage }</MessageBar>;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
renderWpContent =
|
||||
|
||||
<div>
|
||||
<FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={ this.props.refiners }/>
|
||||
<div className="searchWp__count">{ renderCount }</div>
|
||||
{ renderCount }
|
||||
{ renderOverlay }
|
||||
<TilesList items={items.RelevantResults} showFileIcon={this.props.showFileIcon} showCreatedDate={this.props.showCreatedDate} />
|
||||
{this.props.showPaging ?
|
||||
@ -100,46 +110,56 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
|
||||
|
||||
return (
|
||||
<div className="searchWp">
|
||||
{renderWpContent}
|
||||
{ renderWpContent }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
|
||||
try {
|
||||
// Don't perform search is there is no keywords
|
||||
if (this.props.queryKeywords) {
|
||||
try {
|
||||
|
||||
this.setState({
|
||||
areResultsLoading: true,
|
||||
});
|
||||
this.setState({
|
||||
areResultsLoading: true,
|
||||
});
|
||||
|
||||
this.props.searchDataProvider.selectedProperties = this.props.selectedProperties;
|
||||
this.props.searchDataProvider.selectedProperties = this.props.selectedProperties;
|
||||
|
||||
const refinerManagedProperties = Object.keys(this.props.refiners).join(",");
|
||||
const refinerManagedProperties = Object.keys(this.props.refiners).join(",");
|
||||
|
||||
const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, refinerManagedProperties, this.state.selectedFilters, this.state.currentPage);
|
||||
const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, refinerManagedProperties, this.state.selectedFilters, this.state.currentPage);
|
||||
const localizedFilters = await this._getLocalizedFilters(searchResults.RefinementResults);
|
||||
|
||||
// Initial filters are just set once for the filter control during the component initialization
|
||||
// By this way, we are be able to select multiple values whithin a specific filter (OR condition). Otherwise, if we pass every time the new filters retrieved from new results,
|
||||
// previous values will overwritten preventing to select multiple values (default SharePoint behavior)
|
||||
this.setState({
|
||||
results: searchResults,
|
||||
availableFilters: searchResults.RefinementResults,
|
||||
areResultsLoading: false,
|
||||
isComponentLoading: false,
|
||||
lastQuery: this.props.queryKeywords + this.props.searchDataProvider.queryTemplate + this.props.selectedProperties.join(',')
|
||||
});
|
||||
// Initial filters are just set once for the filter control during the component initialization
|
||||
// By this way, we are be able to select multiple values whithin a specific filter (OR condition). Otherwise, if we pass every time the new filters retrieved from new results,
|
||||
// previous values will overwritten preventing to select multiple values (default SharePoint behavior)
|
||||
this.setState({
|
||||
results: searchResults,
|
||||
resultCount: searchResults.TotalRows,
|
||||
availableFilters: localizedFilters,
|
||||
areResultsLoading: false,
|
||||
isComponentLoading: false,
|
||||
lastQuery: this.props.queryKeywords + this.props.searchDataProvider.queryTemplate + this.props.selectedProperties.join(',')
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
} catch (error) {
|
||||
|
||||
Logger.write("[SearchContainer._componentDidMount()]: Error: " + error, LogLevel.Error);
|
||||
Logger.write("[SearchContainer._componentDidMount()]: Error: " + error, LogLevel.Error);
|
||||
|
||||
this.setState({
|
||||
areResultsLoading: false,
|
||||
isComponentLoading: false,
|
||||
results: { RefinementResults: [], RelevantResults: [] },
|
||||
hasError: true,
|
||||
errorMessage: error.message
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
areResultsLoading: false,
|
||||
isComponentLoading: false,
|
||||
results: { RefinementResults: [], RelevantResults: [] },
|
||||
hasError: true,
|
||||
errorMessage: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -158,37 +178,47 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
|
||||
|| this.props.queryKeywords !== nextProps.queryKeywords
|
||||
|| this.props.enableQueryRules !== nextProps.enableQueryRules) {
|
||||
|
||||
try {
|
||||
// Clear selected filters on a new query or new refiners
|
||||
this.setState({
|
||||
selectedFilters: [],
|
||||
areResultsLoading: true,
|
||||
});
|
||||
// Don't perform search is there is no keywords
|
||||
if (nextProps.queryKeywords) {
|
||||
try {
|
||||
// Clear selected filters on a new query or new refiners
|
||||
this.setState({
|
||||
selectedFilters: [],
|
||||
areResultsLoading: true,
|
||||
});
|
||||
|
||||
this.props.searchDataProvider.selectedProperties = nextProps.selectedProperties;
|
||||
this.props.searchDataProvider.selectedProperties = nextProps.selectedProperties;
|
||||
|
||||
const refinerManagedProperties = Object.keys(nextProps.refiners).join(",");
|
||||
const refinerManagedProperties = Object.keys(nextProps.refiners).join(",");
|
||||
|
||||
// We reset the page number and refinement filters
|
||||
const searchResults = await this.props.searchDataProvider.search(nextProps.queryKeywords, refinerManagedProperties, [], 1);
|
||||
// We reset the page number and refinement filters
|
||||
const searchResults = await this.props.searchDataProvider.search(nextProps.queryKeywords, refinerManagedProperties, [], 1);
|
||||
const localizedFilters = await this._getLocalizedFilters(searchResults.RefinementResults);
|
||||
|
||||
this.setState({
|
||||
results: searchResults,
|
||||
availableFilters: searchResults.RefinementResults,
|
||||
areResultsLoading: false,
|
||||
lastQuery: query
|
||||
});
|
||||
this.setState({
|
||||
results: searchResults,
|
||||
resultCount: searchResults.TotalRows,
|
||||
availableFilters: localizedFilters,
|
||||
areResultsLoading: false,
|
||||
lastQuery: query
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
} catch (error) {
|
||||
|
||||
Logger.write("[SearchContainer._componentWillReceiveProps()]: Error: " + error, LogLevel.Error);
|
||||
Logger.write("[SearchContainer._componentWillReceiveProps()]: Error: " + error, LogLevel.Error);
|
||||
|
||||
this.setState({
|
||||
areResultsLoading: false,
|
||||
isComponentLoading: false,
|
||||
results: { RefinementResults: [], RelevantResults: [] },
|
||||
hasError: true,
|
||||
errorMessage: error.message
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
areResultsLoading: false,
|
||||
isComponentLoading: false,
|
||||
results: { RefinementResults: [], RelevantResults: [] },
|
||||
hasError: true,
|
||||
errorMessage: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -237,4 +267,115 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
|
||||
areResultsLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates all refinement results according the current culture
|
||||
* By default SharePoint stores the taxonomy values according to the current site language. Because we can't create a communication site in French (as of 08/12/2017)
|
||||
* we need to do the translation afterwards
|
||||
* @param rawFilters The raw refinement results to translate coming from SharePoint search results
|
||||
*/
|
||||
private async _getLocalizedFilters(rawFilters: IRefinementResult[]): Promise<IRefinementResult[]> {
|
||||
|
||||
let termsToLocalize: { uniqueIdentifier: string, termId: string, localizedTermLabel: string }[] = [];
|
||||
let udpatedFilters = [];
|
||||
|
||||
rawFilters.map((filterResult) => {
|
||||
|
||||
filterResult.Values.map((value) => {
|
||||
|
||||
// Check if the value seems to be a taxonomy term
|
||||
// If the field value looks like a taxonomy value, we get the label according to the current locale
|
||||
// To get this type of values, we need to map the RefinableStringXXX properties with ows_taxId_xxx crawled properties
|
||||
const isTerm = /L0\|#(.+)\|/.test(value.RefinementValue);
|
||||
|
||||
if (isTerm) {
|
||||
const termId = /L0\|#(.+)\|/.exec(value.RefinementValue)[1].substr(1);
|
||||
|
||||
// The uniqueIdentifier is here to be able to match the original value with the localized one
|
||||
// We use the refinement token, which is unique
|
||||
termsToLocalize.push({
|
||||
uniqueIdentifier: value.RefinementToken,
|
||||
termId: termId,
|
||||
localizedTermLabel: null
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (termsToLocalize.length > 0) {
|
||||
|
||||
// Process all terms in a single JSOM call for performance purpose. In general JSOM is pretty slow so we try to limit the number of calls...
|
||||
await this.props.taxonomyDataProvider.initialize();
|
||||
const termValues = await this.props.taxonomyDataProvider.getTermsById(termsToLocalize.map((t)=> { return t.termId; }));
|
||||
|
||||
const termsEnumerator = termValues.getEnumerator();
|
||||
|
||||
while (termsEnumerator.moveNext()) {
|
||||
|
||||
const currentTerm = termsEnumerator.get_current();
|
||||
|
||||
// Need to do this check in the case where the term indexed by the search doesn't exist anymore in the term store
|
||||
if (!currentTerm.get_serverObjectIsNull()) {
|
||||
|
||||
const termId = currentTerm.get_id();
|
||||
|
||||
// Check if retrieved term is part of terms to localize
|
||||
const terms = termsToLocalize.filter((e) => { return e.termId === termId.toString(); });
|
||||
if (terms.length > 0) {
|
||||
termsToLocalize = termsToLocalize.map((term) => {
|
||||
if (term.termId === terms[0].termId) {
|
||||
return {
|
||||
uniqueIdentifier: term.uniqueIdentifier,
|
||||
termId: termId.toString(),
|
||||
localizedTermLabel: termsEnumerator.get_current().get_name(),
|
||||
};
|
||||
} else {
|
||||
return term;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update original filters with localized values
|
||||
rawFilters.map((filter) => {
|
||||
let updatedValues = [];
|
||||
|
||||
filter.Values.map((value) => {
|
||||
const existingFilters = termsToLocalize.filter((e) => { return e.uniqueIdentifier === value.RefinementToken; });
|
||||
if (existingFilters.length > 0) {
|
||||
updatedValues.push({
|
||||
RefinementCount: value.RefinementCount,
|
||||
RefinementName: existingFilters[0].localizedTermLabel,
|
||||
RefinementToken: value.RefinementToken,
|
||||
RefinementValue: existingFilters[0].localizedTermLabel,
|
||||
} as IRefinementValue);
|
||||
} else {
|
||||
|
||||
// Keep only terms (L0). The crawl property ows_taxid_xxx return term sets too.
|
||||
if (!/(GTSet|GPP|GP0)/i.test(value.RefinementName)) {
|
||||
updatedValues.push(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
udpatedFilters.push({
|
||||
FilterName: filter.FilterName,
|
||||
Values: updatedValues.sort((a: IRefinementValue, b: IRefinementValue) => {
|
||||
if (a.RefinementName) {
|
||||
return a.RefinementName.localeCompare(b.RefinementName);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
} as IRefinementResult);
|
||||
});
|
||||
|
||||
} else {
|
||||
// Return filters without any modification
|
||||
udpatedFilters = rawFilters;
|
||||
}
|
||||
|
||||
return udpatedFilters;
|
||||
}
|
||||
}
|
@ -17,6 +17,8 @@ define([], function() {
|
||||
"ShowFileIconLabel": "Show file icons",
|
||||
"ShowCreatedDateLabel": "Show created date",
|
||||
"ShowResultsCountLabel": "Show results count",
|
||||
"ShowBlankLabel": "Show blank if no result",
|
||||
"ShowBlankEditInfoMessage": "No result returned for this query. This Web Part will remain blank in display mode according to parameters.",
|
||||
"NoFilterConfiguredLabel": "No filter configured",
|
||||
"SearchQueryPlaceHolderText": "Search query in KQL format",
|
||||
"EmptyFieldErrorMessage": "This field cannot be empty",
|
||||
|
@ -2,7 +2,7 @@ define([], function() {
|
||||
return {
|
||||
"SearchSettingsGroupName": "Paramètres de recherche",
|
||||
"SearchQueryKeywordsFieldLabel": "Mots clés de recherche",
|
||||
"QueryTemplateFieldLabel": "Modèle de reqûete",
|
||||
"QueryTemplateFieldLabel": "Modèle de requête",
|
||||
"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",
|
||||
@ -17,6 +17,8 @@ define([], function() {
|
||||
"ShowFileIconLabel": "Afficher les icônes de fichier",
|
||||
"ShowCreatedDateLabel": "Afficher la date de création",
|
||||
"ShowResultsCountLabel": "Afficher le nombre de résultats",
|
||||
"ShowBlankLabel": "Ne rien afficher si aucun résultat",
|
||||
"ShowBlankEditInfoMessage": "Aucun résultat pour cette requête. Ce composant Web Part restera vide en mode affichage conformément aux paramètres.",
|
||||
"NoFilterConfiguredLabel": "Aucun filtre configuré",
|
||||
"SearchQueryPlaceHolderText": "Requête de recherche au format KQL",
|
||||
"EmptyFieldErrorMessage": "Ce champ ne peut pas être vide",
|
||||
|
@ -19,6 +19,8 @@ declare interface ISearchWebPartStrings {
|
||||
ShowFileIconLabel: string;
|
||||
ShowCreatedDateLabel: string;
|
||||
ShowResultsCountLabel: string;
|
||||
ShowBlankLabel: string;
|
||||
ShowBlankEditInfoMessage: string;
|
||||
NoFilterConfiguredLabel: string;
|
||||
SearchQueryPlaceHolderText: string;
|
||||
EmptyFieldErrorMessage: string;
|
||||
|
@ -590,33 +590,59 @@
|
||||
argparse "~1.0.9"
|
||||
colors "~1.1.2"
|
||||
|
||||
"@pnp/common@1.0.3", "@pnp/common@^1.0.1":
|
||||
"@pnp/common@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/common/-/common-1.0.4.tgz#ab5d70a08f5cd7e32efe65face7cc60f380bc430"
|
||||
dependencies:
|
||||
"@types/adal-angular" "1.0.0"
|
||||
adal-angular "1.0.17"
|
||||
tslib "1.9.0"
|
||||
|
||||
"@pnp/common@^1.0.1":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/common/-/common-1.0.3.tgz#c590ee4869ced5fa8e289e80546f0f86317b9d1b"
|
||||
dependencies:
|
||||
tslib "1.8.1"
|
||||
|
||||
"@pnp/logging@1.0.3", "@pnp/logging@^1.0.1":
|
||||
"@pnp/logging@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/logging/-/logging-1.0.4.tgz#f32ebaaff5521742b527e0f32a8f8ae89bed11b4"
|
||||
dependencies:
|
||||
tslib "1.9.0"
|
||||
|
||||
"@pnp/logging@^1.0.1":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/logging/-/logging-1.0.3.tgz#013ba264888e63776174398c031700ee744e88dd"
|
||||
dependencies:
|
||||
tslib "1.8.1"
|
||||
|
||||
"@pnp/odata@1.0.3", "@pnp/odata@^1.0.1":
|
||||
"@pnp/odata@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/odata/-/odata-1.0.4.tgz#05ee507aba2e3e7e0f0e4f2e667ac179db61697c"
|
||||
dependencies:
|
||||
tslib "1.9.0"
|
||||
|
||||
"@pnp/odata@^1.0.1":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/odata/-/odata-1.0.3.tgz#ca43f76e7b198483a097ad243098ae8bf769c2a3"
|
||||
dependencies:
|
||||
tslib "1.8.1"
|
||||
|
||||
"@pnp/sp@1.0.3", "@pnp/sp@^1.0.1":
|
||||
"@pnp/sp@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/sp/-/sp-1.0.4.tgz#dd7fc10eb25b61a0313dd64b392da98dc7eab2af"
|
||||
dependencies:
|
||||
tslib "1.9.0"
|
||||
|
||||
"@pnp/sp@^1.0.1":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/sp/-/sp-1.0.3.tgz#25f8919076d44bda70cee93b35c38d741fedcdc8"
|
||||
dependencies:
|
||||
tslib "1.8.1"
|
||||
|
||||
"@pnp/spfx-controls-react@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/spfx-controls-react/-/spfx-controls-react-1.2.3.tgz#c4e718188fe3986cbbfb566be7c58d35cf9fae30"
|
||||
"@pnp/spfx-controls-react@1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/spfx-controls-react/-/spfx-controls-react-1.3.0.tgz#4cc4361ff3c60f52c79072950abbf1a9b5c11a73"
|
||||
dependencies:
|
||||
"@pnp/common" "^1.0.1"
|
||||
"@pnp/logging" "^1.0.1"
|
||||
@ -626,14 +652,18 @@
|
||||
applicationinsights-js "1.0.14"
|
||||
lodash "^4.17.4"
|
||||
|
||||
"@pnp/spfx-property-controls@1.4.2":
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/spfx-property-controls/-/spfx-property-controls-1.4.2.tgz#b30cd9452216440250171bbed55cea42f620e445"
|
||||
"@pnp/spfx-property-controls@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@pnp/spfx-property-controls/-/spfx-property-controls-1.6.0.tgz#0a3398060fb98c83a3cf5b6c5139750ebf105b81"
|
||||
dependencies:
|
||||
"@types/applicationinsights-js" "1.0.5"
|
||||
applicationinsights-js "^1.0.14"
|
||||
react-ace "5.8.0"
|
||||
|
||||
"@types/adal-angular@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/adal-angular/-/adal-angular-1.0.0.tgz#e9f80829c6cb4604be8335c1812ab2532a13ef12"
|
||||
|
||||
"@types/adal@1.0.27":
|
||||
version "1.0.27"
|
||||
resolved "https://registry.yarnpkg.com/@types/adal/-/adal-1.0.27.tgz#20beb326c143451ae462c5af6b251a014fe9e132"
|
||||
@ -701,6 +731,10 @@
|
||||
"@types/events" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/fabric@^1.5.34":
|
||||
version "1.5.34"
|
||||
resolved "https://registry.yarnpkg.com/@types/fabric/-/fabric-1.5.34.tgz#54527df12325e9d934859ea242cb504bced4199d"
|
||||
|
||||
"@types/finalhandler@0.0.31":
|
||||
version "0.0.31"
|
||||
resolved "https://registry.yarnpkg.com/@types/finalhandler/-/finalhandler-0.0.31.tgz#ecf32f3a9bb43a6fb4045750ea1e552582d35dc3"
|
||||
@ -1080,6 +1114,10 @@ adal-angular@1.0.16:
|
||||
version "1.0.16"
|
||||
resolved "https://registry.yarnpkg.com/adal-angular/-/adal-angular-1.0.16.tgz#e2bc31bc712aaffba053aa4dd46bc84eb5d2090f"
|
||||
|
||||
adal-angular@1.0.17:
|
||||
version "1.0.17"
|
||||
resolved "https://registry.yarnpkg.com/adal-angular/-/adal-angular-1.0.17.tgz#6e936e0e41f91d3b2a88e7ffca9c2f6f6f562cc4"
|
||||
|
||||
add-px-to-style@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a"
|
||||
@ -3164,6 +3202,10 @@ double-ended-queue@^2.1.0-0:
|
||||
version "2.1.0-0"
|
||||
resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c"
|
||||
|
||||
downshift@^1.31.14:
|
||||
version "1.31.14"
|
||||
resolved "https://registry.yarnpkg.com/downshift/-/downshift-1.31.14.tgz#98b04614cad2abc4297d0d02b50ff2c48b2625e7"
|
||||
|
||||
duplexer2@0.0.2:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db"
|
||||
@ -9826,7 +9868,7 @@ tslib@1.8.1, tslib@~1.8.0:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.8.1.tgz#6946af2d1d651a7b1863b531d6e5afa41aa44eac"
|
||||
|
||||
tslib@^1.6.0, tslib@^1.7.1, tslib@^1.8.1:
|
||||
tslib@1.9.0, tslib@^1.6.0, tslib@^1.7.1, tslib@^1.8.1:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.0.tgz#e37a86fda8cbbaf23a057f473c9f4dc64e5fc2e8"
|
||||
|
||||
@ -9914,6 +9956,10 @@ typedarray@^0.0.6, typedarray@~0.0.5:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||
|
||||
typescript@^2.8.3:
|
||||
version "2.8.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.8.3.tgz#5d817f9b6f31bb871835f4edf0089f21abe6c170"
|
||||
|
||||
typescript@~2.4.1:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.4.2.tgz#f8395f85d459276067c988aa41837a8f82870844"
|
||||
|
Loading…
x
Reference in New Issue
Block a user