[react-search-refiners] Added Handlebars templating feature + Replaced event aggregator by dynamic data from SPFx 1.5.1-plusbeta (#550)

* [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.

* [react-search-refiners]
* Added a templating feature for search results with Handlebars inspired by the react-content-query-webpart sample.
* Upgraded to 1.5.1-plusbeta to use the new SPFx dynamic data feature instead of event aggregator for Web Parts communication.
* Code refactoring and reorganization.

* * Removed unused test sample folder
This commit is contained in:
Franck Cornu 2018-07-02 05:44:04 -04:00 committed by Vesa Juvonen
parent 3a1f2e92ed
commit 48ac608381
81 changed files with 27187 additions and 6399 deletions

View File

@ -3,6 +3,9 @@
"version": "1.3.2", "version": "1.3.2",
"libraryName": "react-search-refiners", "libraryName": "react-search-refiners",
"libraryId": "890affef-33e0-4d72-bd72-36399e02143b", "libraryId": "890affef-33e0-4d72-bd72-36399e02143b",
"environment": "spo" "environment": "spo",
"isCreatingSolution": false,
"componentType": "extension",
"extensionType": "ApplicationCustomizer"
} }
} }

View File

@ -1,4 +1,4 @@
# SharePoint Framework search with refiners and paging sample # SharePoint Framework search with search box, refiners and paging sample
## Summary ## Summary
This sample shows you how to build user friendly SharePoint search experiences using Office UI fabric tiles, custom refiners, paging and suggestions. This sample shows you how to build user friendly SharePoint search experiences using Office UI fabric tiles, custom refiners, paging and suggestions.
@ -10,7 +10,7 @@ This sample shows you how to build user friendly SharePoint search experiences u
An associated [blog post](http://thecollaborationcorner.com/2017/10/16/build-dynamic-sharepoint-search-experiences-with-refiners-and-paging-with-spfx-office-ui-fabric-and-pnp-js-library/) is available to give you more details about this sample implementation. An associated [blog post](http://thecollaborationcorner.com/2017/10/16/build-dynamic-sharepoint-search-experiences-with-refiners-and-paging-with-spfx-office-ui-fabric-and-pnp-js-library/) is available to give you more details about this sample implementation.
## Used SharePoint Framework Version ## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/drop-1.4.1-green.svg) ![drop](https://img.shields.io/badge/drop-1.5.1--plusbeta-blue.svg)
## Applies to ## Applies to
@ -21,7 +21,7 @@ An associated [blog post](http://thecollaborationcorner.com/2017/10/16/build-dyn
Solution|Author(s) Solution|Author(s)
--------|--------- --------|---------
react-search-refiners | Franck Cornu (MVP Office Development at aequos) - [@FranckCornu](http://www.twitter.com/FranckCornu)<br/>Mikael Svenson -[@mikaelsvenson](http://www.twitter.com/mikaelsvenson) react-search-refiners | Franck Cornu (aequos) - [@FranckCornu](http://www.twitter.com/FranckCornu)<br/>Mikael Svenson -[@mikaelsvenson](http://www.twitter.com/mikaelsvenson)
## Version history ## Version history
@ -32,6 +32,7 @@ Version|Date|Comments
1.2 | February 12, 2018 | Added a search box Web Part + Added a "Result Source Id" and "Enable Query Rules" parameters. 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.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> 1.4 | May 10, 2018 | <ul><li>Added the query suggestions feature to the search box Web Part</li><li>Added the automatic translation for taxonomy filter values according to the current site locale.</li> <li>Added the option in the search box Web Part to send the query to an other page</ul>
1.5 | Jul 2, 2018 | <ul><li>Added a templating feature for search results with Handlebars inspired by the [react-content-query-webpart](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-content-query-webpart) sample.</li><li>Upgraded to 1.5.1-plusbeta to use the new SPFx dynamic data feature instead of event aggregator for Web Parts communication.</li> <li>Code refactoring and reorganization.</ul>
## Disclaimer ## 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.** **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.**
@ -45,26 +46,48 @@ Version|Date|Comments
- `npm install` - `npm install`
- `gulp serve` - `gulp serve`
### Web Part property pane options ### Web Part Configuration ###
The following settings are available in the Web Part property pane: The following settings are available in the Web Part property pane:
<p align="center"><img src="./images/property_pane.png"/><p> <table>
<tr>
<td>
<p align="center"><img src="./images/property_pane.png"/><p>
</td>
<td>
<p align="center"><img src="./images/property_pane2.png"/><p>
</td>
</tr>
<table>
#### Search Query Configuration ####
Setting | Description
-------|----
Search query keywords | Here you choose to use a static search query or a query coming from a search box Web Part on a page or the "q" URL query string parameter. The search query is in KQL format so 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). You can only plug one source to this Web Part.
<p align="center"><img src="./images/wp_connection.png"/><p>
#### Search Settings ####
Setting | Description 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}). Query template | The search query template in KQL format. You can use search variables here (like Path:{Site}).
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. 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 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) . Selected properties | The search managed properties to retrieve. You can use these properties then in the code like this (`item.property_name`).
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",... 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. 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.
#### Styling Options ####
Setting | Description
-------|----
Show blank if no result | Shows nothing if there is no result Show blank if no result | Shows nothing if there is no result
Show result count | Shows the result count and entered keywords 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 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 Result Layouts options | Choose the template to use to display search results. Some layouts are defined by default (List oand Tiles) but you can create your own either by clinkg on the **"Custom"** tile, or **"Edit template"** from an existing chosen template. In custom mode, you can set an external template. It has to be in the same SharePoint tenant. Behind the scenes, the Office UI Fabric core CSS components are used in a isolated way.
Show created date | Shows the created date for individual result
### Taxonomy values dynamic translation ### Taxonomy values dynamic translation
@ -76,12 +99,39 @@ This Web Part supports the translation for taxonomy based filters according to c
### Query suggestions ### 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). The search box supports query suggestions from SharePoint. 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).
### Templates with Handlebars ###
This Web Part allows you change customize the way you display your search results. The templating feature comes directly from the original [react-content-query-webpart](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-content-query-webpart) so thanks to @spplante!
<p align="center">
<img src="./images/edit_template.png"/>
</p>
#### Available tokens ####
Setting | Description
-------|----
`{{showResultsCount}}` | Boolean flag corresponding to the associated in the property pane.
`{{totalRows}}` | The result count.
`{{keywords}}` | The search query.
`{{getSummary HitHighlightedSummary}}` | Format the *HitHighlightedSummary* property with recognized words in bold.
`{{getDate <date_managed_property> "<format>}}"` | Format the date with moment.ts according to the current language.
`{{getPreviewSrc item}}` | Determine the image thumbnail URL if applicable.
`{{getUrl item}}` | Get the item URL. For a document, it means the URL to the Office Online instance or the direct URL (to download it).
`{{getCountMessage totalRows <?keywords>}}` | Display a friendly message displaying the result and the entered keywords.
`{{<search_managed_property_name>}}` | Any valid search managed property returned in the results set. These are typically managed properties set in the *"Selected properties"* setting in the property pane. You don't need to prefix them with `item.` if you are in the "each" loop.
Also the [Handlebars helpers](https://github.com/helpers/handlebars-helpers) (188 helpers) are also available. You can also define your own in the *BaseTemplateService.ts* file.
## Features ## Features
This Web Part illustrates the following concepts on top of the SharePoint Framework: 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, refiners and query suggestions using the *@pnp* JavaScript 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.
- Use [Handlebars](https://handlebarsjs.com/) to create templates for search results according to your requirements like the good old display templates.
- Using the SPFx [dynamic data feature](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/dynamic-data) to connect Web Parts and/or Extensions.
- Using SharePoint taxonomy using JSOM in SPFx (filter translations)
- Integrate the [@pnp/spfx-property-controls](https://github.com/SharePoint/sp-dev-fx-property-controls) in your solution (*PlaceHolder* control). - 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. - Integrate multiple Office UI Fabric components (DocumentCard, Panel, GroupedList, ...) to fit with the native Office 365 theme.
- Use the React container component approach inspiring by the [react-todo-basic sample](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-todo-basic). - Use 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).

View File

@ -2,19 +2,19 @@
"$schema": "https://dev.office.com/json-schemas/spfx-build/config.2.0.schema.json", "$schema": "https://dev.office.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0", "version": "2.0",
"bundles": { "bundles": {
"search-web-part": { "search-web-parts": {
"components": [ "components": [
{ {
"entrypoint": "./lib/webparts/searchResults/SearchResultsWebPart.js", "entrypoint": "./lib/webparts/searchResults/SearchResultsWebPart.js",
"manifest": "./src/webparts/searchResults/SearchResultsWebPart.manifest.json" "manifest": "./src/webparts/searchResults/SearchResultsWebPart.manifest.json"
}
]
}, },
"search-box-web-part": {
"components": [
{ {
"entrypoint": "./lib/webparts/searchBox/SearchBoxWebPart.js", "entrypoint": "./lib/webparts/searchBox/SearchBoxWebPart.js",
"manifest": "./src/webparts/searchBox/SearchBoxWebPart.manifest.json" "manifest": "./src/webparts/searchBox/SearchBoxWebPart.manifest.json"
},
{
"entrypoint": "./lib/extensions/queryStringDataSource/QueryStringDataSourceApplicationCustomizer.js",
"manifest": "./src/extensions/queryStringDataSource/QueryStringDataSourceApplicationCustomizer.manifest.json"
} }
] ]
} }
@ -24,6 +24,8 @@
"localizedResources": { "localizedResources": {
"SearchWebPartStrings": "lib/webparts/searchResults/loc/{locale}.js", "SearchWebPartStrings": "lib/webparts/searchResults/loc/{locale}.js",
"PropertyControlStrings": "./node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js", "PropertyControlStrings": "./node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js",
"SearchBoxWebPartStrings": "lib/webparts/searchBox/loc/{locale}.js" "SearchBoxWebPartStrings": "lib/webparts/searchBox/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js",
"QueryStringDataSourceApplicationCustomizerStrings": "lib/extensions/queryStringDataSource/loc/{locale}.js"
} }
} }

View File

@ -3,9 +3,22 @@
"solution": { "solution": {
"name": "PnP - Search Web Parts", "name": "PnP - Search Web Parts",
"id": "890affef-33e0-4d72-bd72-36399e02143b", "id": "890affef-33e0-4d72-bd72-36399e02143b",
"version": "2.0.0.0", "version": "2.0.0.3",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": true "skipFeatureDeployment": false,
"features": [
{
"title": "Application Extension - Deployment of custom action.",
"description": "Deploys a custom action with ClientSideComponentId association",
"id": "b28f9c2b-6e10-4764-8585-5f8e6533001b",
"version": "1.0.0.0",
"assets": {
"elementManifests": [
"elements.xml"
]
}
}
]
}, },
"paths": { "paths": {
"zippedPackage": "solution/pnp-react-search-refiners.sppkg" "zippedPackage": "solution/pnp-react-search-refiners.sppkg"

View File

@ -6,5 +6,20 @@
"api": { "api": {
"port": 5432, "port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/" "entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
},
"serveConfigurations": {
"default": {
"pageUrl": "https://localhost:5432/workbench"
},
"queryStringDataSource": {
"pageUrl": "https://collaborationcorner.sharepoint.com/teams/PnPIntranet/SitePages/Search(1).aspx",
"customActions": {
"24cae67d-dec7-4eff-bb41-49451d5b5a11": {
"location": "ClientSideExtension.ApplicationCustomizer",
"properties": {
}
}
}
}
} }
} }

View File

@ -3,4 +3,33 @@
const gulp = require('gulp'); const gulp = require('gulp');
const build = require('@microsoft/sp-build-web'); const build = require('@microsoft/sp-build-web');
/********************************************************************************************
* Adds an alias for handlebars in order to avoid errors while gulping the project
* https://github.com/wycats/handlebars.js/issues/1174
* Adds a loader and a node setting for webpacking the handlebars-helpers correctly
* https://github.com/helpers/handlebars-helpers/issues/263
********************************************************************************************/
build.configureWebpack.mergeConfig({
additionalConfiguration: (generatedConfiguration) => {
generatedConfiguration.resolve.alias = { handlebars: 'handlebars/dist/handlebars.min.js' };
generatedConfiguration.module.rules.push(
{
test: /utils\.js$/,
loader: 'unlazy-loader',
include: [
/node_modules/,
]
}
);
generatedConfiguration.node = {
fs: 'empty'
}
return generatedConfiguration;
}
});
build.initialize(gulp); build.initialize(gulp);

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 7.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "react-search-refiners", "name": "react-search-refiners",
"version": "0.0.2", "version": "1.5.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -11,40 +11,49 @@
"test": "gulp test" "test": "gulp test"
}, },
"dependencies": { "dependencies": {
"@microsoft/sp-core-library": "~1.4.1", "react": "15.6.2",
"@microsoft/sp-lodash-subset": "~1.4.1", "react-dom": "15.6.2",
"@microsoft/sp-office-ui-fabric-core": "~1.4.1",
"@microsoft/sp-webpart-base": "~1.4.1",
"@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": "15.6.6",
"@types/react-dom": "15.5.6", "@types/react-dom": "15.5.6",
"@microsoft/sp-loader": "1.5.1-plusbeta",
"@microsoft/sp-core-library": "1.5.1-plusbeta",
"@microsoft/sp-webpart-base": "1.5.1-plusbeta",
"@microsoft/sp-lodash-subset": "1.5.1-plusbeta",
"@microsoft/sp-office-ui-fabric-core": "1.5.1-plusbeta",
"@types/webpack-env": "1.13.1",
"@types/es6-promise": "0.0.33",
"@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.5.0",
"@pnp/spfx-property-controls": "1.7.0",
"@types/fabric": "^1.5.34",
"@types/handlebars": "^4.0.38",
"@types/sharepoint": "2013.1.9", "@types/sharepoint": "2013.1.9",
"@types/webpack-env": ">=1.12.1 <1.14.0", "common-tags": "^1.8.0",
"downshift": "^1.31.14", "downshift": "^1.31.14",
"handlebars": "4.0.11",
"handlebars-helpers": "0.8.2",
"immutability-helper": "2.4.0", "immutability-helper": "2.4.0",
"lodash-es": "4.17.4", "lodash-es": "4.17.4",
"moment": "2.21.0", "moment": "2.21.0",
"office-ui-fabric-react": "5.21.0", "office-ui-fabric-react": "5.21.0",
"react": "15.6.2", "react-ace": "^6.1.1",
"react-custom-scrollbars": "4.1.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" "@microsoft/decorators": "1.5.1-plusbeta",
"@microsoft/sp-dialog": "1.5.1-plusbeta",
"@microsoft/sp-application-base": "1.5.1-plusbeta"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/sp-build-web": "~1.4.1", "@microsoft/sp-build-web": "1.5.1-plusbeta",
"@microsoft/sp-module-interfaces": "~1.4.1", "@microsoft/sp-module-interfaces": "1.5.1-plusbeta",
"@microsoft/sp-webpart-workbench": "~1.4.1", "@microsoft/sp-webpart-workbench": "1.5.1-plusbeta",
"@types/chai": ">=3.4.34 <3.6.0", "gulp": "~3.9.1",
"@types/jquery": "2.0.48", "@types/chai": "3.4.34",
"@types/mocha": ">=2.2.33 <2.6.0", "@types/mocha": "2.2.38",
"ajv": "~5.2.2", "ajv": "~5.2.2",
"gulp": "~3.9.1" "unlazy-loader": "0.1.2"
} }
} }

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<CustomAction
Title="QueryStringDataSource"
Location="ClientSideExtension.ApplicationCustomizer"
ClientSideComponentId="24cae67d-dec7-4eff-bb41-49451d5b5a11"
ClientSideComponentProperties="{&quot;testMessage&quot;:&quot;Test message&quot;}">
</CustomAction>
</Elements>

View File

@ -0,0 +1,17 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-extension-manifest.schema.json",
"id": "24cae67d-dec7-4eff-bb41-49451d5b5a11",
"alias": "URLQueryStringParameters",
"componentType": "Extension",
"extensionType": "ApplicationCustomizer",
// 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
}

View File

@ -0,0 +1,93 @@
import { override } from '@microsoft/decorators';
import { Log } from '@microsoft/sp-core-library';
import {
BaseApplicationCustomizer
} from '@microsoft/sp-application-base';
import * as strings from 'QueryStringDataSourceApplicationCustomizerStrings';
import { IDynamicDataController, IDynamicDataPropertyDefinition } from '@microsoft/sp-dynamic-data';
import { UrlHelper } from '../../helpers/UrlHelper';
const LOG_SOURCE: string = 'QueryStringDataSourceApplicationCustomizer';
/**
* If your command set uses the ClientSideComponentProperties JSON input,
* it will be deserialized into the BaseExtension.properties object.
* You can define an interface to describe it.
*/
export interface IQueryStringDataSourceApplicationCustomizerProperties {
}
/** A Custom Action which can be run during execution of a Client Side Application */
export default class QueryStringDataSourceApplicationCustomizer
extends BaseApplicationCustomizer<IQueryStringDataSourceApplicationCustomizerProperties> implements IDynamicDataController {
private _searchQuery: string;
@override
public onInit(): Promise<void> {
Log.info(LOG_SOURCE, `Initialized ${strings.Title}`);
// 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._searchQuery = queryStringKeywords;
} else {
this._searchQuery = '';
}
// Used as data source name
this.manifest.alias = strings.Title;
this._bindPushState();
this.context.dynamicDataSourceManager.initializeSource(this);
return Promise.resolve();
}
public getPropertyValue(propertyId: string) {
switch (propertyId) {
case 'queryStringQuery':
return decodeURIComponent(this._searchQuery);
default:
throw new Error('Bad property id');
}
}
public getPropertyDefinitions(): ReadonlyArray<IDynamicDataPropertyDefinition> {
return [
{
id: 'queryStringQuery',
title: strings.QQueryStringParameter
}
];
}
/**
* Adds a event listener on the default push state event and updates the data source property
*/
private _bindPushState() {
const _pushState = () => {
const _defaultPushState = history.pushState;
const _self = this;
return function (data: any, title: string, url?: string | null) {
const queryStringKeywords = UrlHelper.getQueryStringParam("q", url);
if (queryStringKeywords && queryStringKeywords !== _self._searchQuery) {
_self._searchQuery = queryStringKeywords;
_self.context.dynamicDataSourceManager.notifyPropertyChanged('queryStringQuery');
}
// Call the original function with the provided arguments
// This context is necessary for the context of the history change
return _defaultPushState.apply(this, [data, title, url]);
};
};
history.pushState = _pushState();
}
}

View File

@ -0,0 +1,6 @@
define([], function() {
return {
"Title": "URL Query String Parameter",
"QQueryStringParameter": "'q' parameter value"
}
});

View File

@ -0,0 +1,6 @@
define([], function() {
return {
"Title": "Paramètre de requête de l'URL",
"QQueryStringParameter": "Valeur du paramètre 'q'"
}
});

View File

@ -0,0 +1,9 @@
declare interface IQueryStringDataSourceApplicationCustomizerStrings {
Title: string;
QQueryStringParameter: string;
}
declare module 'QueryStringDataSourceApplicationCustomizerStrings' {
const strings: IQueryStringDataSourceApplicationCustomizerStrings;
export = strings;
}

View File

@ -1,4 +1,4 @@
import { IRefinementFilter } from "./ISearchResult"; import { IRefinementFilter } from './ISearchResult';
type RefinementFilterOperationCallback = (filters: IRefinementFilter[]) => void; type RefinementFilterOperationCallback = (filters: IRefinementFilter[]) => void;

View File

@ -0,0 +1,7 @@
enum ResultsLayoutOption {
List,
Tiles,
Custom
}
export default ResultsLayoutOption;

View File

@ -1,6 +1,6 @@
import { IRefinementFilter, ISearchResults } from "../../models/ISearchResult"; import { ISearchResults, IRefinementFilter } from '../../models/ISearchResult';
interface ISearchDataProvider { interface ISearchService {
/** /**
* Determines the number of items ot retrieve in REST requests * Determines the number of items ot retrieve in REST requests
@ -28,8 +28,8 @@ interface ISearchDataProvider {
enableQueryRules?: boolean; enableQueryRules?: boolean;
/** /**
* Performs a search query. * Perfoms a search query.
* @returns ISearchResults object. Use the "RelevantResults" property to acces results proeprties (returned as key/value pair object => item.[<Managed property name>]) * @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>; search(kqlQuery: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise<ISearchResults>;
@ -40,4 +40,4 @@ interface ISearchDataProvider {
suggest(query: string): Promise<string[]>; suggest(query: string): Promise<string[]>;
} }
export default ISearchDataProvider; export default ISearchService;

View File

@ -1,15 +1,12 @@
import ISearchDataProvider from "./ISearchDataProvider"; import ISearchService from './ISearchService';
import { ISearchResults, IRefinementFilter, ISearchResult } from "../../models/ISearchResult"; import { ISearchResults, IRefinementFilter, ISearchResult } from '../../models/ISearchResult';
import intersection from "lodash-es/intersection"; import intersection from 'lodash-es/intersection';
import clone from "lodash-es/clone"; import clone from 'lodash-es/clone';
class MockSearchDataProvider implements ISearchDataProvider { class MockSearchService implements ISearchService {
public queryTemplate?: string;
public resultSourceId?: string;
public enableQueryRules?: boolean;
public selectedProperties: string[]; public selectedProperties: string[];
private _suggestions: string[];
private _itemsCount: number; private _itemsCount: number;
@ -17,86 +14,85 @@ class MockSearchDataProvider implements ISearchDataProvider {
public set resultsCount(value: number) { this._itemsCount = value; } public set resultsCount(value: number) { this._itemsCount = value; }
private _searchResults: ISearchResults; private _searchResults: ISearchResults;
private _suggestions: string[];
public constructor() { public constructor() {
this._searchResults = { this._searchResults = {
RelevantResults: [ RelevantResults: [
{ {
Title: "Document 1 - Category 1", Title: 'Document 1 - Category 1',
Url: "http://document1.ca", Path: 'http://document1.ca',
Created: "2017-07-22T15:38:54.0000000Z", Created: '2017-07-22T15:38:54.0000000Z',
RefinementTokenValues: "ǂǂ446f63756d656e74,ǂǂ45647563617465", RefinementTokenValues: 'ǂǂ446f63756d656e74,ǂǂ45647563617465',
ContentCategory: "Document", ContentCategory: 'Document',
}, },
{ {
Title: "Document 2 - Category 2", Title: 'Document 2 - Category 2',
Url: "http://document2.ca", Path: 'http://document2.ca',
Created: "2017-07-22T15:38:54.0000000Z", Created: '2017-07-22T15:38:54.0000000Z',
RefinementTokenValues: "ǂǂ446f63756d656e74,ǂǂ416476697365", RefinementTokenValues: 'ǂǂ446f63756d656e74,ǂǂ416476697365',
ContentCategory: "Document", ContentCategory: 'Document',
}, },
{ {
Title: "Form 1", Title: 'Form 1',
Url: "http://form1.ca", Path: 'http://form1.ca',
Created: "2017-07-22T15:38:54.0000000Z", Created: '2017-07-22T15:38:54.0000000Z',
RefinementTokenValues: "ǂǂ466f726d", RefinementTokenValues: 'ǂǂ466f726d',
ContentCategory: "Form", ContentCategory: 'Form',
}, },
{ {
Title: "Video 1 - Category 1", Title: 'Video 1 - Category 1',
Url: "https://www.youtube.com/watch?v=S93e6UU7y9o", Path: 'https://www.youtube.com/watch?v=S93e6UU7y9o',
Created: "2017-07-22T15:38:54.0000000Z", Created: '2017-07-22T15:38:54.0000000Z',
RefinementTokenValues: "ǂǂ566964656f,ǂǂ45647563617465", RefinementTokenValues: 'ǂǂ566964656f,ǂǂ45647563617465',
ContentCategory: "Video", ContentCategory: 'Video',
}, },
{ {
Title: "Video 2 - Category 2", Title: 'Video 2 - Category 2',
Url: "https://www.youtube.com/watch?v=8Nl_dKVQ1O8", Path: 'https://www.youtube.com/watch?v=8Nl_dKVQ1O8',
Created: "2017-07-22T15:38:54.0000000Z", Created: '2017-07-22T15:38:54.0000000Z',
RefinementTokenValues: "ǂǂ566964656f,ǂǂ416476697365", RefinementTokenValues: 'ǂǂ566964656f,ǂǂ416476697365',
ContentCategory: "Video", ContentCategory: 'Video',
}, },
], ],
RefinementResults: [ RefinementResults: [
{ {
FilterName: "Type", FilterName: 'Type',
Values: [ Values: [
{ {
RefinementCount: 2, RefinementCount: 2,
RefinementName: "Document", RefinementName: 'Document',
RefinementToken: "ǂǂ446f63756d656e74", RefinementToken: 'ǂǂ446f63756d656e74',
RefinementValue: "Document", RefinementValue: 'Document',
}, },
{ {
RefinementCount: 2, RefinementCount: 2,
RefinementName: "Video", RefinementName: 'Video',
RefinementToken: "ǂǂ566964656f", RefinementToken: 'ǂǂ566964656f',
RefinementValue: "Video", RefinementValue: 'Video',
}, },
{ {
RefinementCount: 1, RefinementCount: 1,
RefinementName: "Form", RefinementName: 'Form',
RefinementToken: "ǂǂ466f726d", RefinementToken: 'ǂǂ466f726d',
RefinementValue: "Form", RefinementValue: 'Form',
} }
] ]
}, },
{ {
FilterName: "Theme", FilterName: 'Theme',
Values: [ Values: [
{ {
RefinementCount: 2, RefinementCount: 2,
RefinementName: "Category 1", RefinementName: 'Category 1',
RefinementToken: "ǂǂ45647563617465", RefinementToken: 'ǂǂ45647563617465',
RefinementValue: "Category 1", RefinementValue: 'Category 1',
}, },
{ {
RefinementCount: 2, RefinementCount: 2,
RefinementName: "Category 2", RefinementName: 'Category 2',
RefinementToken: "ǂǂ416476697365", RefinementToken: 'ǂǂ416476697365',
RefinementValue: "Category 2", RefinementValue: 'Category 2',
}, },
] ]
} }
@ -131,7 +127,7 @@ class MockSearchDataProvider implements ISearchDataProvider {
}); });
searchResults.RelevantResults.map((searchResult) => { searchResults.RelevantResults.map((searchResult) => {
const filtered = intersection(filters, searchResult.RefinementTokenValues.split(",")); const filtered = intersection(filters, searchResult.RefinementTokenValues.split(','));
if (filtered.length > 0) { if (filtered.length > 0) {
filteredResults.push(searchResult); filteredResults.push(searchResult);
} }
@ -156,6 +152,14 @@ class MockSearchDataProvider implements ISearchDataProvider {
return p1; 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 );
}
public async suggest(keywords: string): Promise<string[]> { public async suggest(keywords: string): Promise<string[]> {
let proposedSuggestions: string[] = []; let proposedSuggestions: string[] = [];
@ -183,14 +187,6 @@ class MockSearchDataProvider implements ISearchDataProvider {
return p1; 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; export default MockSearchService;

View File

@ -1,23 +1,21 @@
import ISearchDataProvider from "./ISearchDataProvider"; import ISearchService from './ISearchService';
import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter } from "../../models/ISearchResult"; import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter } from '../../models/ISearchResult';
import { sp, SearchQuery, SearchQueryBuilder, SearchResults, SPRest, Web, Sort, SortDirection, SearchSuggestQuery } from "@pnp/sp"; import { sp, SearchQuery, SearchResults, SPRest, Web, Sort, SortDirection, SearchSuggestQuery } from '@pnp/sp';
import { PnPClientStorage, Util } from "@pnp/common"; import { Logger, LogLevel, ConsoleListener } from '@pnp/logging';
import { Logger, LogLevel, ConsoleListener } from "@pnp/logging"; import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { IWebPartContext } from "@microsoft/sp-webpart-base"; import { Text } from '@microsoft/sp-core-library';
import { Text, JsonUtilities, UrlUtilities } from "@microsoft/sp-core-library"; import sortBy from 'lodash-es/sortBy';
import sortBy from "lodash-es/sortBy";
import groupBy from 'lodash-es/groupBy'; import groupBy from 'lodash-es/groupBy';
import mapValues from 'lodash-es/mapValues'; import mapValues from 'lodash-es/mapValues';
import mapKeys from "lodash-es/mapKeys"; import mapKeys from 'lodash-es/mapKeys';
import * as moment from "moment"; import * as moment from 'moment';
import LocalizationHelper from "../../helpers/LocalizationHelper"; import LocalizationHelper from '../../helpers/LocalizationHelper';
class SearchDataProvider implements ISearchDataProvider { class SearchService implements ISearchService {
private _initialSearchResult: SearchResults = null; private _initialSearchResult: SearchResults = null;
private _resultsCount: number; private _resultsCount: number;
private _context: IWebPartContext; private _context: IWebPartContext;
private _appSearchSettings: SearchQuery;
private _selectedProperties: string[]; private _selectedProperties: string[];
private _queryTemplate: string; private _queryTemplate: string;
private _resultSourceId: string; private _resultSourceId: string;
@ -52,7 +50,7 @@ class SearchDataProvider implements ISearchDataProvider {
// We use a local configuration to avoid conflicts with other Web Parts // We use a local configuration to avoid conflicts with other Web Parts
this._localPnPSetup= sp.configure({ this._localPnPSetup= sp.configure({
headers: { headers: {
Accept: "application/json; odata=nometadata", Accept: 'application/json; odata=nometadata',
}, },
}, this._context.pageContext.web.absoluteUrl); }, this._context.pageContext.web.absoluteUrl);
} }
@ -70,7 +68,7 @@ class SearchDataProvider implements ISearchDataProvider {
// Search paging option is one based // Search paging option is one based
let page = pageNumber ? pageNumber : 1; let page = pageNumber ? pageNumber : 1;
searchQuery.ClientType = "ContentSearchRegular"; searchQuery.ClientType = 'ContentSearchRegular';
searchQuery.Querytext = query; searchQuery.Querytext = query;
// Disable query rules by default if not specified // Disable query rules by default if not specified
@ -90,11 +88,11 @@ class SearchDataProvider implements ISearchDataProvider {
let sortList: Sort[] = [ let sortList: Sort[] = [
{ {
Property: "Created", Property: 'Created',
Direction: SortDirection.Descending Direction: SortDirection.Descending
}, },
{ {
Property: "Size", Property: 'Size',
Direction: SortDirection.Ascending Direction: SortDirection.Ascending
} }
]; ];
@ -103,8 +101,8 @@ class SearchDataProvider implements ISearchDataProvider {
if (refiners) { if (refiners) {
// Get the refiners order specified in the property pane // Get the refiners order specified in the property pane
sortedRefiners = refiners.split(","); sortedRefiners = refiners.split(',');
searchQuery.Refiners = refiners ? refiners : ""; searchQuery.Refiners = refiners ? refiners : '';
} }
if (refinementFilters) { if (refinementFilters) {
@ -141,7 +139,7 @@ class SearchDataProvider implements ISearchDataProvider {
const resultRows = r2.RawSearchResults.PrimaryQueryResult.RelevantResults.Table.Rows; const resultRows = r2.RawSearchResults.PrimaryQueryResult.RelevantResults.Table.Rows;
let refinementResultsRows = r2.RawSearchResults.PrimaryQueryResult.RefinementResults; let refinementResultsRows = r2.RawSearchResults.PrimaryQueryResult.RefinementResults;
const refinementRows = refinementResultsRows ? refinementResultsRows["Refiners"] : []; const refinementRows = refinementResultsRows ? refinementResultsRows['Refiners'] : [];
// Map search results // Map search results
resultRows.map((elt) => { resultRows.map((elt) => {
@ -157,7 +155,7 @@ class SearchDataProvider implements ISearchDataProvider {
}); });
// Get the icon source URL // Get the icon source URL
this._mapToIcon(result.Filename ? result.Filename : Text.format(".{0}", result.FileExtension)).then((iconUrl) => { this._mapToIcon(result.Filename ? result.Filename : Text.format('.{0}', result.FileExtension)).then((iconUrl) => {
result.iconSrc = iconUrl; result.iconSrc = iconUrl;
resolvep1(result); resolvep1(result);
@ -206,7 +204,7 @@ class SearchDataProvider implements ISearchDataProvider {
return results; return results;
} catch (error) { } catch (error) {
Logger.write("[SharePointDataProvider.search()]: Error: " + error, LogLevel.Error); Logger.write('[SharePointDataProvider.search()]: Error: ' + error, LogLevel.Error);
throw error; throw error;
} }
} }
@ -257,14 +255,14 @@ class SearchDataProvider implements ISearchDataProvider {
const web = new Web(webAbsoluteUrl); const web = new Web(webAbsoluteUrl);
try { try {
const encodedFileName = filename ? filename.replace(/["']/g, "") : ""; const encodedFileName = filename ? filename.replace(/['']/g, '') : '';
const iconFileName = await web.mapToIcon(encodedFileName, 1); const iconFileName = await web.mapToIcon(encodedFileName, 1);
const iconUrl = webAbsoluteUrl + "/_layouts/15/images/" + iconFileName; const iconUrl = webAbsoluteUrl + '/_layouts/15/images/' + iconFileName;
return iconUrl; return iconUrl;
} catch (error) { } catch (error) {
Logger.write("[SharePointDataProvider._mapToIcon()]: Error: " + error, LogLevel.Error); Logger.write('[SharePointDataProvider._mapToIcon()]: Error: ' + error, LogLevel.Error);
throw error; throw error;
} }
} }
@ -282,7 +280,7 @@ class SearchDataProvider implements ISearchDataProvider {
if (matches) { if (matches) {
matches.map(match => { matches.map(match => {
updatedInputValue = updatedInputValue.replace(match, moment(match).format("LL")); updatedInputValue = updatedInputValue.replace(match, moment(match).format('LL'));
}); });
} }
@ -303,11 +301,11 @@ class SearchDataProvider implements ISearchDataProvider {
return filter.Value.RefinementToken; return filter.Value.RefinementToken;
}); });
return refinementFilter.length > 1 ? Text.format("or({0})", refinementFilter) : refinementFilter.toString(); return refinementFilter.length > 1 ? Text.format('or({0})', refinementFilter) : refinementFilter.toString();
}); });
mapKeys(refinementFilters, (value, key) => { mapKeys(refinementFilters, (value, key) => {
refinementQueryConditions.push(key + ":" + value); refinementQueryConditions.push(key + ':' + value);
}); });
const conditionsCount = refinementQueryConditions.length; const conditionsCount = refinementQueryConditions.length;
@ -328,7 +326,7 @@ class SearchDataProvider implements ISearchDataProvider {
// Multiple filters // Multiple filters
case (conditionsCount > 1): { case (conditionsCount > 1): {
refinementQueryString = Text.format("and({0})", refinementQueryConditions.toString()); refinementQueryString = Text.format('and({0})', refinementQueryConditions.toString());
break; break;
} }
} }
@ -337,4 +335,4 @@ class SearchDataProvider implements ISearchDataProvider {
} }
} }
export default SearchDataProvider; export default SearchService;

View File

@ -1,4 +1,4 @@
interface ITaxonomyDataProvider { interface ITaxonomyService {
/** /**
* Ensure all script dependencies are loaded before using the taxonomy SharePoint CSOM functions * Ensure all script dependencies are loaded before using the taxonomy SharePoint CSOM functions
@ -15,4 +15,4 @@ interface ITaxonomyDataProvider {
getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection>; getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection>;
} }
export default ITaxonomyDataProvider; export default ITaxonomyService;

View File

@ -1,7 +1,7 @@
import ITaxonomyDataProvider from "./ITaxonomyDataProvider"; import ITaxonomyService from './ITaxonomyService';
class MockTaxonomyDataProvider implements ITaxonomyDataProvider { class MockTaxonomyDataProvider implements ITaxonomyService {
public initialize(): Promise<void> { public initialize(): Promise<void> {
const p1 = new Promise<void>((resolve, reject) => { const p1 = new Promise<void>((resolve, reject) => {
@ -12,7 +12,7 @@ class MockTaxonomyDataProvider implements ITaxonomyDataProvider {
} }
public getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection> { public getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection> {
throw new Error("Method not implemented."); throw new Error('Method not implemented.');
} }
} }

View File

@ -1,18 +1,16 @@
import { IWebPartContext } from "@microsoft/sp-webpart-base"; import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { Logger, LogLevel, ConsoleListener } from "@pnp/logging"; import { Logger, LogLevel, ConsoleListener } from '@pnp/logging';
import { SPComponentLoader } from '@microsoft/sp-loader'; import { SPComponentLoader } from '@microsoft/sp-loader';
import ITaxonomyDataProvider from "./ITaxonomyDataProvider"; import ITaxonomyService from './ITaxonomyService';
import { Text } from "@microsoft/sp-core-library"; import { Text } from '@microsoft/sp-core-library';
class TaxonomyProvider implements ITaxonomyDataProvider { class TaxonomyService implements ITaxonomyService {
private _workingLanguageLcid: number; private _workingLanguageLcid: number;
private _context: IWebPartContext; private _context: IWebPartContext;
private _isInitialized: boolean;
public constructor(webPartContext: IWebPartContext, workingLanguage?: number){ public constructor(webPartContext: IWebPartContext, workingLanguage?: number){
this._context = webPartContext; this._context = webPartContext;
this._isInitialized = false;
this._workingLanguageLcid = workingLanguage ? workingLanguage : null; this._workingLanguageLcid = workingLanguage ? workingLanguage : null;
} }
@ -130,4 +128,4 @@ class TaxonomyProvider implements ITaxonomyDataProvider {
} }
} }
export default TaxonomyProvider; export default TaxonomyService;

View File

@ -0,0 +1,192 @@
import * as Handlebars from 'handlebars';
import { ISearchResult } from '../../models/ISearchResult';
import { html } from 'common-tags';
import { isEmpty } from '@microsoft/sp-lodash-subset';
import * as strings from 'SearchWebPartStrings';
import { Text } from '@microsoft/sp-core-library';
import * as moment from 'moment';
abstract class BaseTemplateService {
constructor() {
// Registers all helpers
this.registerTemplateServices();
// Imports the handlebars-helpers
let helpers = require<any>('handlebars-helpers')({
handlebars: Handlebars
});
}
/**
* Gets the default Handlebars list item template used in list layout
* @returns the template HTML markup
*/
public static getListDefaultTemplate(): string {
return html`
<div class="template_root">
{{#if showResultsCount}}
<div class="template_resultCount">
<label class="ms-fontWeight-semibold">{{getCountMessage totalRows keywords}}</label>
</div>
{{/if}}
<ul class="ms-List template_defaultList">
{{#each items as |item|}}
<li class="ms-ListItem ms-ListItem--image" tabindex="0">
<div class="ms-ListItem-image template_icon" style="background-image:url('{{iconSrc}}')">
</div>
<span class="ms-ListItem-primaryText"><a href="{{getUrl item}}">{{Title}}</a></span>
<span class="ms-ListItem-secondaryText">{{getSummary HitHighlightedSummary}}</span>
<span class="ms-ListItem-tertiaryText">{{getDate Created "LL"}}</span>
<div class="ms-ListItem-selectionTarget"></div>
</li>
{{/each}}
</ul>
</div>
`;
}
/**
* Gets the default Handlebars list item template used in list layout
* @returns the template HTML markup
*/
public static getTilesDefaultTemplate(): string {
return html`
<div class="template_root">
<div class="template_defaultCard">
{{#if showResultsCount}}
<div class="template_resultCount">
<label class="ms-fontWeight-semibold">{{getCountMessage totalRows keywords}}</label>
</div>
{{/if}}
<div class="ms-Grid">
<div class="ms-Grid-row">
{{#each items as |item|}}
<div class="ms-Grid-col ms-sm12 ms-md6 ms-lg4">
<div class="singleCard">
<div class="previewImg" style="background-image: url('{{getPreviewSrc item}}')">
<img class="cardFileIcon" src="{{iconSrc}}"/>
</div>
<li class="ms-ListItem ms-ListItem--document" tabindex="0">
<div class="cardInfo">
<span class="ms-ListItem-primaryText"><a href="{{getUrl item}}">{{Title}}</a></span>
<span class="ms-ListItem-secondaryText">{{getSummary HitHighlightedSummary}}</span>
<span class="ms-ListItem-tertiaryText">{{getDate Created "LL"}}</span>
<div class="ms-ListItem-selectionTarget"></div>
</div>
</li>
</div>
</div>
{{/each}}
</div>
</div>
</div>
</div>
`;
}
/**
* Gets the default Handlebars custom blank item template
* @returns the template HTML markup
*/
public static getBlankDefaultTemplate(): string {
return html`
<style>
/* Insert your CSS here */
</style>
<div class="template_root">
<ul class="ms-List">
{{#each items as |item|}}
<li class="ms-ListItem ms-ListItem--image" tabindex="0">
<span class="ms-ListItem-primaryText"><a href="{{getUrl item}}">{{Title}}</a></span>
</li>
{{/each}}
</ul>
</div>
`;
}
/**
* Registers useful helpers for search results templates
*/
private registerTemplateServices() {
// Return the URL of the search result item
// Usage: <a href="{{url item}}">
Handlebars.registerHelper("getUrl", (item: ISearchResult) => {
if (!isEmpty(item))
return item.ServerRedirectedURL ? item.ServerRedirectedURL : item.Path;
});
// Return the search result count message
// Usage: {{getCountMessage totalRows keywords}} or {{getCountMessage totalRows null}}
Handlebars.registerHelper("getCountMessage", (totalRows: string, inputQuery?: string) => {
const countResultMessage = inputQuery ? Text.format(strings.CountMessageLong, totalRows, inputQuery) : Text.format(strings.CountMessageShort, totalRows);
return new Handlebars.SafeString(countResultMessage);
});
// Return the preview image URL for the search result item
// Usage: <img src="{{previewSrc item}}""/>
Handlebars.registerHelper("getPreviewSrc", (item: ISearchResult) => {
let previewSrc = "";
if (item) {
if (!isEmpty(item.SiteLogo)) previewSrc = item.SiteLogo;
else if (!isEmpty(item.PreviewUrl)) previewSrc = item.PreviewUrl;
else if (!isEmpty(item.PictureThumbnailURL)) previewSrc = item.PictureThumbnailURL;
else if (!isEmpty(item.ServerRedirectedPreviewURL)) previewSrc = item.ServerRedirectedPreviewURL;
}
return previewSrc;
});
// Return the highlighted summary of the search result item
// <p>{{summary HitHighlightedSummary}}</p>
Handlebars.registerHelper("getSummary", (hitHighlightedSummary: string) => {
if (!isEmpty(hitHighlightedSummary)) {
return new Handlebars.SafeString(hitHighlightedSummary.replace(/c0/g, "strong").replace(/<ddd\/>/g, "&#8230;"));
}
});
// Return the formatted date according to current locale using moment.js
// <p>{{getDate Created "LL"}}</p>
Handlebars.registerHelper("getDate", (date: string, format: string) => {
if (moment(date).isValid()) {
return moment(date).format(format);
}
});
}
/**
* Compile the specified Handlebars template with the associated context object¸
* @returns the compiled HTML template string
*/
public processTemplate(templateContext: any, templateContent: string): string {
// Process the Handlebars template
let template = Handlebars.compile(templateContent);
let result = template(templateContext);
return result;
}
/**
* Verifies if the template fiel path is correct
* @param filePath the file path string
*/
public static isValidTemplateFile(filePath: string): boolean {
let path = filePath.toLowerCase().trim();
let pathExtension = path.substring(path.lastIndexOf('.'));
return (pathExtension == '.htm' || pathExtension == '.html');
}
public abstract getFileContent(fileUrl: string): Promise<string>;
public abstract ensureFileResolves(fileUrl: string): Promise<void>;
}
export default BaseTemplateService;

View File

@ -0,0 +1,40 @@
import { html } from 'common-tags';
import BaseTemplateService from './BaseTemplateService';
class MockTemplateService extends BaseTemplateService {
private readonly _mockFileContent: string = html`
<div class='template_root'>
<span><strong>Mocked external template</strong></span>
{{#if showResultsCount}}
<div class='template_resultCount'>
<label class='ms-fontWeight-semibold'>{{getCountMessage totalRows keywords}}</label>
</div>
{{/if}}
<ul class='ms-List template_defaultList'>
{{#each items as |item|}}
<li class='ms-ListItem ms-ListItem--image' tabindex='0'>
<span class='ms-ListItem-primaryText'><a href='{{getUrl item}}'>{{Title}}</a></span>
</li>
{{/each}}
</ul>
</div>
`;
public getFileContent(fileUrl: string): Promise<string> {
const p1 = new Promise<string>((resolve) => {
setTimeout(() => {
resolve(this._mockFileContent);
}, 1000);
});
return p1;
}
public ensureFileResolves(fileUrl: string): Promise<void> {
return Promise.resolve();
}
}
export default MockTemplateService;

View File

@ -0,0 +1,59 @@
import BaseTemplateService from './BaseTemplateService';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
class TemplateService extends BaseTemplateService {
private _spHttpClient: SPHttpClient;
constructor(spHttpClient: SPHttpClient) {
super();
this._spHttpClient = spHttpClient;
}
/**
* Gets the external file content from the specified URL
* @param fileUrl the file URL
*/
public async getFileContent(fileUrl: string): Promise<string> {
try {
const response: SPHttpClientResponse = await this._spHttpClient.get(fileUrl, SPHttpClient.configurations.v1);
if(response.ok) {
return await response.text();
}
else {
throw response.statusText;
}
} catch (error) {
throw error;
}
}
/**
* Ensures the file is accessible trough the specified URL
* @param filePath the file URL
*/
public async ensureFileResolves(fileUrl: string): Promise<void> {
try {
const response: SPHttpClientResponse = await this._spHttpClient.get(fileUrl, SPHttpClient.configurations.v1);
if(response.ok) {
if(response.url.indexOf('AccessDenied.aspx') > -1){
throw 'Access Denied';
}
return;
}
else {
throw response.statusText;
}
} catch (error) {
throw error;
}
}
}
export default TemplateService;

View File

@ -0,0 +1,6 @@
import { IPropertyPaneCustomFieldProps } from '@microsoft/sp-webpart-base';
import { IPropertyPaneTextDialogProps } from './IPropertyPaneTextDialogProps';
export interface IPropertyPaneTextDialogInternalProps extends IPropertyPaneTextDialogProps, IPropertyPaneCustomFieldProps {
}

View File

@ -0,0 +1,8 @@
import { ITextDialogStrings } from './components/TextDialog/ITextDialogStrings';
export interface IPropertyPaneTextDialogProps {
dialogTextFieldValue?: string;
onPropertyChange: (propertyPath: string, text: string) => void;
disabled?: boolean;
strings: ITextDialogStrings;
}

View File

@ -0,0 +1,74 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { IPropertyPaneField, PropertyPaneFieldType } from '@microsoft/sp-webpart-base';
import { IPropertyPaneTextDialogProps } from './IPropertyPaneTextDialogProps';
import { IPropertyPaneTextDialogInternalProps } from './IPropertyPaneTextDialogInternalProps';
import { TextDialog } from './components/TextDialog/TextDialog';
import { ITextDialogProps } from './components/TextDialog/ITextDialogProps';
export class PropertyPaneTextDialog implements IPropertyPaneField<IPropertyPaneTextDialogProps> {
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
public targetProperty: string;
public properties: IPropertyPaneTextDialogInternalProps;
private elem: HTMLElement;
/*****************************************************************************************
* Property pane's contructor
* @param targetProperty
* @param properties
*****************************************************************************************/
constructor(targetProperty: string, properties: IPropertyPaneTextDialogProps) {
this.targetProperty = targetProperty;
this.properties = {
dialogTextFieldValue: properties.dialogTextFieldValue,
onPropertyChange: properties.onPropertyChange,
disabled: properties.disabled,
strings: properties.strings,
onRender: this.onRender.bind(this),
key: targetProperty
};
}
/*****************************************************************************************
* Renders the QueryFilterPanel property pane
*****************************************************************************************/
public render(): void {
if (!this.elem) {
return;
}
this.onRender(this.elem);
}
/*****************************************************************************************
* Renders the QueryFilterPanel property pane
*****************************************************************************************/
private onRender(elem: HTMLElement): void {
if (!this.elem) {
this.elem = elem;
}
const textDialog: React.ReactElement<ITextDialogProps> = React.createElement(TextDialog, {
dialogTextFieldValue: this.properties.dialogTextFieldValue,
onChanged: this.onChanged.bind(this),
disabled: this.properties.disabled,
strings: this.properties.strings,
// required to allow the component to be re-rendered by calling this.render() externally
stateKey: new Date().toString()
});
ReactDom.render(textDialog, elem);
}
/*****************************************************************************************
* Call the property pane's onPropertyChange when the TextDialog changes
*****************************************************************************************/
private onChanged(text: string): void {
this.properties.onPropertyChange(this.targetProperty, text);
}
}

View File

@ -0,0 +1,3 @@
.ace_editor.ace_autocomplete {
z-index: 2000000 !important;
}

View File

@ -0,0 +1,9 @@
import { ITextDialogStrings } from "./ITextDialogStrings";
export interface ITextDialogProps {
dialogTextFieldValue?: string;
onChanged?: (text: string) => void;
disabled?: boolean;
strings: ITextDialogStrings;
stateKey?: string;
}

View File

@ -0,0 +1,4 @@
export interface ITextDialogState {
dialogText: string;
showDialog: boolean;
}

View File

@ -0,0 +1,9 @@
export interface ITextDialogStrings {
dialogTitle: string;
dialogSubText?: string;
dialogButtonLabel?: string;
dialogButtonText: string;
dialogTextBoxPlaceholder?: string;
saveButtonText: string;
cancelButtonText: string;
}

View File

@ -0,0 +1,121 @@
import * as React from 'react';
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react';
import { Button, ButtonType, Label } from 'office-ui-fabric-react';
import { TextField } from 'office-ui-fabric-react';
import { ITextDialogProps } from './ITextDialogProps';
import { ITextDialogState } from './ITextDialogState';
import AceEditor from 'react-ace';
import styles from './TextDialog.module.scss';
import './AceEditor.module.scss';
import 'brace';
import 'brace/mode/html';
import 'brace/theme/monokai';
import 'brace/ext/language_tools';
export class TextDialog extends React.Component<ITextDialogProps, ITextDialogState> {
/*************************************************************************************
* Component's constructor
* @param props
* @param state
*************************************************************************************/
constructor(props: ITextDialogProps, state: ITextDialogState) {
super(props);
this.state = { dialogText: this.props.dialogTextFieldValue, showDialog: false };
}
/*************************************************************************************
* Shows the dialog
*************************************************************************************/
private showDialog() {
this.setState({ dialogText: this.state.dialogText, showDialog: true });
}
/*************************************************************************************
* Notifies the parent with the dialog's latest value, then closes the dialog
*************************************************************************************/
private saveDialog() {
this.setState({ dialogText: this.state.dialogText, showDialog: false });
if(this.props.onChanged) {
this.props.onChanged(this.state.dialogText);
}
}
/*************************************************************************************
* Closes the dialog without notifying the parent for any changes
*************************************************************************************/
private cancelDialog() {
this.setState({ dialogText: this.state.dialogText, showDialog: false });
}
/*************************************************************************************
* Updates the dialog's value each time the textfield changes
*************************************************************************************/
private onDialogTextChanged(newValue: string) {
this.setState({ dialogText: newValue, showDialog: this.state.showDialog });
}
/*************************************************************************************
* Called immediately after updating occurs
*************************************************************************************/
public componentDidUpdate(prevProps: ITextDialogProps, prevState: ITextDialogState): void {
if (this.props.disabled !== prevProps.disabled || this.props.stateKey !== prevProps.stateKey) {
this.setState({ dialogText: this.props.dialogTextFieldValue, showDialog: this.state.showDialog });
}
}
/*************************************************************************************
* Renders the the TextDialog component
*************************************************************************************/
public render() {
return (
<div>
<Label>{ this.props.strings.dialogButtonLabel }</Label>
<Button label={ this.props.strings.dialogButtonLabel }
onClick={ this.showDialog.bind(this) }
disabled={ this.props.disabled }>
{ this.props.strings.dialogButtonText }
</Button>
<Dialog type={ DialogType.normal }
isOpen={ this.state.showDialog }
onDismiss={ this.cancelDialog.bind(this) }
title={ this.props.strings.dialogTitle }
subText={ this.props.strings.dialogSubText }
isBlocking={ true }
modalProps={
{
containerClassName: 'ms-dialogMainOverride ' + styles.textDialog,
}
}>
<AceEditor
width="600px"
mode="html"
theme="monokai"
enableLiveAutocompletion={ true }
showPrintMargin={ false }
showGutter= { true }
onChange={ this.onDialogTextChanged.bind(this) }
value={ this.state.dialogText }
name="CodeEditor"
/>
<DialogFooter>
<Button buttonType={ ButtonType.primary } onClick={ this.saveDialog.bind(this) }>{ this.props.strings.saveButtonText }</Button>
<Button onClick={ this.cancelDialog.bind(this) }>{ this.props.strings.cancelButtonText }</Button>
</DialogFooter>
</Dialog>
</div>
);
}
}

View File

@ -1,77 +0,0 @@
/**
* 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;

View File

@ -0,0 +1,14 @@
import { PageOpenBehavior } from '../../helpers/UrlHelper';
interface ISearchBoxWebPartProps {
searchInNewPage: boolean;
pageUrl: string;
openBehavior: PageOpenBehavior;
enableQuerySuggestions: boolean;
useDynamicDataSource: boolean;
dynamicDataSourceId: string;
dynamicDataSourcePropertyId: string;
dynamicDataSourceComponentId: string;
}
export default ISearchBoxWebPartProps;

View File

@ -4,6 +4,10 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
*::-ms-clear {
display: none;
}
.suggestionItem { .suggestionItem {
padding: 10px; padding: 10px;
} }

View File

@ -1,105 +1,179 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDom from 'react-dom'; import * as ReactDom from 'react-dom';
import { Version, Environment, EnvironmentType } from '@microsoft/sp-core-library';
import { import {
BaseClientSideWebPart, BaseClientSideWebPart,
IPropertyPaneConfiguration, IPropertyPaneConfiguration,
PropertyPaneTextField, PropertyPaneTextField,
PropertyPaneCheckbox, PropertyPaneCheckbox,
PropertyPaneDropdown, PropertyPaneDropdown,
PropertyPaneLabel, IPropertyPaneField,
IPropertyPaneDropdownOption,
PropertyPaneToggle, PropertyPaneToggle,
} from '@microsoft/sp-webpart-base'; } from '@microsoft/sp-webpart-base';
import * as strings from 'SearchBoxWebPartStrings'; import * as strings from 'SearchBoxWebPartStrings';
import SearchBox from './components/SearchBoxContainer'; import SearchBox from './components/SearchBoxContainer';
import { ISearchBoxProps } from './components/ISearchBoxContainerProps'; import { ISearchBoxContainerProps } from './components/ISearchBoxContainerProps';
import { PageOpenBehavior } from '../common/UrlHelper'; import { PageOpenBehavior } from '../../helpers/UrlHelper';
import ISearchDataProvider from '../dataProviders/SearchDataProvider/ISearchDataProvider'; import { IDynamicDataController, IDynamicDataSourceMetadata, IDynamicDataPropertyDefinition, IDynamicDataSource } from '@microsoft/sp-dynamic-data';
import MockSearchDataProvider from '../dataProviders/SearchDataProvider/MockSearchDataProvider'; import ISearchBoxWebPartProps from './ISearchBoxWebPartProps';
import SearchDataProvider from '../dataProviders/SearchDataProvider/SearchDataProvider'; import { Log, Text, Environment, EnvironmentType } from '@microsoft/sp-core-library';
import ISearchService from '../../services/SearchService/ISearchService';
import MockSearchService from '../../services/SearchService/MockSearchService';
import SearchService from '../../services/SearchService/SearchService';
export interface ISearchBoxWebPartProps { const LOG_SOURCE: string = '[SearchBoxWebPart_{0}]';
export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWebPartProps> implements IDynamicDataController {
private _searchService: ISearchService;
private _searchQuery: string;
private _source: IDynamicDataSource;
private _domElement: HTMLElement;
/** /**
* Indicates if we should show the query suggestions when typing * Used to be able to unregister dynamic data events if the source is updated
*/ */
enableQuerySuggestions: boolean; private _lastSourceId: string = undefined;
private _lastPropertyId: string = undefined;
/** constructor() {
* Indicates if we should send the query to a new page super();
*/ this._searchQuery = '';
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( * Handler used to notify data source subscribers when the input query is updated
SearchBox, { */
enableQuerySuggestions: this.properties.enableQuerySuggestions, private _onSearch = (query: string): void => {
searchDataProvider: this._searchDataProvider, this._searchQuery = query;
eventAggregator: this.context.eventAggregator, this.context.dynamicDataSourceManager.notifyPropertyChanged('inputQuery');
searchInNewPage: this.properties.searchInNewPage, }
pageUrl: this.properties.pageUrl,
openBehavior: this.properties.openBehavior /**
* Resolves the connected data sources
* Useful in the case when the data source comes from an extension,
* the id is regenerated every time the page is refreshed causing the property pane configuration be lost
*/
private _initDynamicDataSource() {
if (this.properties.dynamicDataSourceId
&& this.properties.dynamicDataSourcePropertyId
&& this.properties.dynamicDataSourceComponentId) {
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
let sourceId = undefined;
if (this._source) {
sourceId = this._source.id;
} else {
// Try to resolve the source and get its id by the name
this._source = this._tryGetSourceByComponentId(this.properties.dynamicDataSourceComponentId);
sourceId = this._source ? this._source.id : undefined;
}
if (sourceId) {
this.context.dynamicDataProvider.registerPropertyChanged(sourceId, this.properties.dynamicDataSourcePropertyId, this._dataSourceUpdated);
this._searchQuery = this._source.getPropertyValue(this.properties.dynamicDataSourcePropertyId);
// Update the property for the property pane
this.properties.dynamicDataSourceId = sourceId;
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
// Notify subscriber of the initial value
this.context.dynamicDataSourceManager.notifyPropertyChanged('inputQuery');
// If false, means the onInit method is not completed yet so we let it render the web part through the normal process
if (this.renderedOnce) {
this.render();
}
}
}
}
/**
* Determines the group fields for the search query options inside the property pane
*/
private _getSearchQueryFields(): IPropertyPaneField<any>[] {
// Sets up search query fields
let searchQueryConfigFields: IPropertyPaneField<any>[] = [
PropertyPaneCheckbox('useDynamicDataSource', {
checked: false,
text: strings.UseDynamicDataSourceLabel,
})
];
if (this.properties.useDynamicDataSource) {
const sourceOptions: IPropertyPaneDropdownOption[] =
this.context.dynamicDataProvider.getAvailableSources().map(source => {
return {
key: source.id,
text: source.metadata.title,
componentId: source.metadata.componentId
};
}).filter((item) => {
if (item.key.localeCompare("PageContext") !== 0 && item.componentId !== this.componentId) {
return item;
}
}); });
ReactDom.render(element, this.domElement); const selectedSource: string = this.properties.dynamicDataSourceId;
}
protected get dataVersion(): Version { let propertyOptions: IPropertyPaneDropdownOption[] = [];
return Version.parse('1.0'); if (selectedSource) {
} const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource);
if (source) {
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void { propertyOptions = source.getPropertyDefinitions().map(prop => {
// Initializes data provider on first load according to property pane configuration
this.initSearchDataProvider();
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return { return {
pages: [ key: prop.id,
{ text: prop.title
groups: [ };
{ });
groupName: strings.SearchBoxNewPage, }
groupFields: [ }
PropertyPaneCheckbox("searchInNewPage", {
searchQueryConfigFields = searchQueryConfigFields.concat([
PropertyPaneDropdown('dynamicDataSourceId', {
label: "Source",
options: sourceOptions,
selectedKey: this.properties.dynamicDataSourceId,
}),
PropertyPaneDropdown('dynamicDataSourcePropertyId', {
disabled: !this.properties.dynamicDataSourceId,
label: "Source property",
options: propertyOptions,
selectedKey: this.properties.dynamicDataSourcePropertyId
}),
]);
}
return searchQueryConfigFields;
}
/**
* Determines the group fields for the search options inside the property pane
*/
private _getSearchBehaviorOptionsFields(): IPropertyPaneField<any>[] {
let searchBehaviorOptionsFields: IPropertyPaneField<any>[] = [
PropertyPaneToggle("enableQuerySuggestions", {
checked: false,
label: strings.SearchBoxEnableQuerySuggestions
}),
PropertyPaneCheckbox('searchInNewPage', {
text: strings.SearchBoxSearchInNewPageLabel text: strings.SearchBoxSearchInNewPageLabel
}), })
PropertyPaneLabel("", { ];
text: strings.SearchBoxSearchInNewPageDescription
}), if (this.properties.searchInNewPage) {
PropertyPaneTextField("pageUrl", { searchBehaviorOptionsFields = searchBehaviorOptionsFields.concat([
PropertyPaneTextField('pageUrl', {
disabled: !this.properties.searchInNewPage, disabled: !this.properties.searchInNewPage,
label: strings.SearchBoxPageUrlLabel, label: strings.SearchBoxPageUrlLabel,
onGetErrorMessage: this._validateUrl.bind(this) onGetErrorMessage: this._validateUrl.bind(this)
}), }),
PropertyPaneDropdown("openBehavior", { PropertyPaneDropdown('openBehavior', {
label: strings.SearchBoxPageOpenBehaviorLabel, label: strings.SearchBoxPageOpenBehaviorLabel,
options: [ options: [
{ key: PageOpenBehavior.Self, text: strings.SearchBoxSameTabOpenBehavior, index: 0 }, { key: PageOpenBehavior.Self, text: strings.SearchBoxSameTabOpenBehavior, index: 0 },
@ -108,44 +182,176 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
disabled: !this.properties.searchInNewPage, disabled: !this.properties.searchInNewPage,
selectedKey: 0 selectedKey: 0
}) })
] ]);
},
{
groupName: strings.SearchBoxQuerySuggestionsSettings,
groupFields: [
PropertyPaneToggle("enableQuerySuggestions", {
checked: false,
label: strings.SearchBoxEnableQuerySuggestions
})
]
} }
]
} return searchBehaviorOptionsFields;
]
};
} }
/** /**
* Initializes the query optimization data provider instance according to the current environnement * Handler to notify data source subscribers the query string value has been updated
*/ */
private initSearchDataProvider() { private _dataSourceUpdated() {
if (this.properties.enableQuerySuggestions) { if (this.properties.useDynamicDataSource) {
if (Environment.type === EnvironmentType.Local ) { if (this.properties.dynamicDataSourceId && this.properties.dynamicDataSourcePropertyId) {
this._searchDataProvider = new MockSearchDataProvider(); this._searchQuery = this._source ? this._source.getPropertyValue(this.properties.dynamicDataSourcePropertyId) : undefined;
} else { this.context.dynamicDataSourceManager.notifyPropertyChanged('inputQuery');
this._searchDataProvider = new SearchDataProvider(this.context);
return ""; this.render();
} }
} }
} }
/**
* Verifies if the string is a correct URL
* @param value the URL to verify
*/
private _validateUrl(value: string) { private _validateUrl(value: string) {
if ((!/^(https?):\/\/[^\s/$.?#].[^\s]*/.test(value) || !value) && this.properties.searchInNewPage) { if ((!/^(https?):\/\/[^\s/$.?#].[^\s]*/.test(value) || !value) && this.properties.searchInNewPage) {
return strings.SearchBoxUrlErrorMessage; return strings.SearchBoxUrlErrorMessage;
} }
return '';
}
/**
* Gets a dynamic data source by its component id. The component id doesn't change when the page is refreshed
* @param dataSourceComponentId the component id
*/
private _tryGetSourceByComponentId(dataSourceComponentId: string): IDynamicDataSource {
const resolvedDataSource = this.context.dynamicDataProvider.getAvailableSources()
.filter((item) => {
if (item.metadata.componentId) {
if (item.metadata.componentId.localeCompare(dataSourceComponentId) === 0) {
return item;
}
}
});
if (resolvedDataSource.length > 0 ) {
return resolvedDataSource[0];
} else {
Log.verbose(Text.format(LOG_SOURCE, "_tryGetSourceByComponentId()"), `Unable to find dynamic data source with componentId '${dataSourceComponentId}'`);
return undefined;
}
}
/**
* Initializes the query suggestions data provider instance according to the current environnement
*/
private initSearchService() {
if (this.properties.enableQuerySuggestions) {
if (Environment.type === EnvironmentType.Local ) {
this._searchService = new MockSearchService();
} else {
this._searchService = new SearchService(this.context);
return ""; return "";
} }
}
}
protected onInit(): Promise<void> {
this._domElement = this.domElement;
this.initSearchService();
this.context.dynamicDataSourceManager.initializeSource(this);
// Make sure the data source will be plugged in correctly when loaded on the page
// Depending of the component loading order, some sources may be unavailable at this time so that's why we use an event listener
this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
return Promise.resolve();
}
protected onPropertyPaneFieldChanged(changedProperty: string) {
this.initSearchService();
if (changedProperty === 'dynamicDataSourceId') {
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
this.properties.dynamicDataSourcePropertyId = this._source.getPropertyDefinitions()[0].id;
this.properties.dynamicDataSourceComponentId = this._source.metadata.componentId;
// Unregister previous event listeners is the source is updated
if (this._lastSourceId && this._lastPropertyId) {
// Check if the source is still on the page so we can unregister
if (this.context.dynamicDataProvider.tryGetSource(this._lastSourceId)) {
this.context.dynamicDataProvider.unregisterPropertyChanged(this._lastSourceId, this._lastPropertyId, this.render);
}
}
this.context.dynamicDataProvider.registerPropertyChanged(this.properties.dynamicDataSourceId, this.properties.dynamicDataSourcePropertyId, this._dataSourceUpdated);
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
}
if (changedProperty === 'useDynamicDataSource') {
if (!this.properties.useDynamicDataSource) {
this.context.dynamicDataProvider.unregisterAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
}
}
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
groups: [
{
groupName: strings.SearchBoxDynamicDataSourceGroupName,
groupFields: this._getSearchQueryFields()
},
{
groupName: strings.SearchBoxNewPage,
groupFields: this._getSearchBehaviorOptionsFields()
}
],
displayGroupsAsAccordion: true
}
]
};
}
public getPropertyValue(propertyId: string) {
switch (propertyId) {
case 'inputQuery':
return this._searchQuery;
default:
throw new Error('Bad property id');
}
}
public getPropertyDefinitions(): ReadonlyArray<IDynamicDataPropertyDefinition> {
return [
{
id: 'inputQuery',
title: strings.SearchBoxDynamicPropertyInputLabel
}
];
}
public render(): void {
const element: React.ReactElement<ISearchBoxContainerProps> = React.createElement(
SearchBox, {
onSearch: this._onSearch,
searchInNewPage: this.properties.searchInNewPage,
pageUrl: this.properties.pageUrl,
openBehavior: this.properties.openBehavior,
inputValue: this._searchQuery,
enableQuerySuggestions: this.properties.enableQuerySuggestions,
searchService: this._searchService
} as ISearchBoxContainerProps);
ReactDom.render(element, this._domElement);
}
} }

View File

@ -1,12 +1,12 @@
import { IEventAggregator } from "@microsoft/sp-webpart-base"; import { PageOpenBehavior } from '../../../helpers/UrlHelper';
import { PageOpenBehavior } from "../../common/UrlHelper"; import ISearchService from '../../../services/SearchService/ISearchService';
import ISearchDataProvider from "../../dataProviders/SearchDataProvider/ISearchDataProvider";
export interface ISearchBoxProps { export interface ISearchBoxContainerProps {
eventAggregator: IEventAggregator; onSearch: (query: string) => void;
enableQuerySuggestions: boolean;
searchDataProvider: ISearchDataProvider;
searchInNewPage: boolean; searchInNewPage: boolean;
enableQuerySuggestions: boolean;
searchService: ISearchService;
pageUrl: string; pageUrl: string;
openBehavior: PageOpenBehavior; openBehavior: PageOpenBehavior;
inputValue: string;
} }

View File

@ -1,4 +1,4 @@
export interface ISearchBoxContainerState { interface ISearchBoxContainerState {
/** /**
* List of proposed suggestions in the dropdown list * List of proposed suggestions in the dropdown list
@ -30,3 +30,5 @@ export interface ISearchBoxContainerState {
*/ */
errorMessage: string; errorMessage: string;
} }
export default ISearchBoxContainerState;

View File

@ -1,63 +1,39 @@
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 * as React from 'react';
import "../SearchBoxWebPart.scss"; import { ISearchBoxContainerProps } from './ISearchBoxContainerProps';
import { ISearchBoxProps } from './ISearchBoxContainerProps'; import * as strings from 'SearchBoxWebPartStrings';
import { ISearchBoxContainerState } from './ISearchBoxContainerState'; import ISearchBoxContainerState from './ISearchBoxContainerState';
import * as update from "immutability-helper"; import { UrlHelper, PageOpenBehavior } from '../../../helpers/UrlHelper';
import { UrlHelper, PageOpenBehavior } from '../../common/UrlHelper'; import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import Downshift from 'downshift';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import { IconType } from 'office-ui-fabric-react/lib/Icon';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { Label } from 'office-ui-fabric-react/lib/Label';
import * as update from 'immutability-helper';
import '../SearchBoxWebPart.scss';
export default class SearchBoxContainer extends React.Component<ISearchBoxProps, ISearchBoxContainerState> { const SUGGESTION_CHAR_COUNT_TRIGGER = 3;
private readonly SUGGESTION_CHAR_COUNT_TRIGGER = 3; export default class SearchBoxContainer extends React.Component<ISearchBoxContainerProps, ISearchBoxContainerState> {
public constructor() { public constructor() {
super(); super();
this._onSearch = this._onSearch.bind(this);
this._onChange = this._onChange.bind(this);
this._onQuerySuggestionSelected = this._onQuerySuggestionSelected.bind(this);
this.state = { this.state = {
proposedQuerySuggestions: [], proposedQuerySuggestions: [],
selectedQuerySuggestions: [], selectedQuerySuggestions: [],
isRetrievingSuggestions: false, isRetrievingSuggestions: false,
searchInputValue: null, searchInputValue: '',
termToSuggestFrom: null, termToSuggestFrom: null,
errorMessage: null errorMessage: null
}; };
this._onSearch = this._onSearch.bind(this);
this._onChange = this._onChange.bind(this);
this._onQuerySuggestionSelected = this._onQuerySuggestionSelected.bind(this);
} }
public render(): React.ReactElement<ISearchBoxProps> { private renderSearchBoxWithAutoComplete(): JSX.Element {
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 return <Downshift
onSelect={ this._onQuerySuggestionSelected } onSelect={ this._onQuerySuggestionSelected }
> >
@ -65,7 +41,6 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxProps,
getInputProps, getInputProps,
getItemProps, getItemProps,
isOpen, isOpen,
inputValue,
selectedItem, selectedItem,
highlightedIndex, highlightedIndex,
openMenu, openMenu,
@ -92,7 +67,6 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxProps,
if (this.state.selectedQuerySuggestions.length === 0) { if (this.state.selectedQuerySuggestions.length === 0) {
clearItems(); clearItems();
this._onChange(value); this._onChange(value);
openMenu(); openMenu();
} else { } else {
@ -110,14 +84,14 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxProps,
iconType: IconType.default iconType: IconType.default
}}/> }}/>
{isOpen ? {isOpen ?
this.renderSuggestions(getItemProps, openMenu, selectedItem, highlightedIndex) this.renderSuggestions(getItemProps, selectedItem, highlightedIndex)
: null} : null}
</div> </div>
)} )}
</Downshift>; </Downshift>;
} }
public renderBasicSearchBox(): JSX.Element { private renderBasicSearchBox(): JSX.Element {
return <TextField return <TextField
placeholder={ strings.SearchInputPlaceholder } placeholder={ strings.SearchInputPlaceholder }
value={ this.state.searchInputValue } value={ this.state.searchInputValue }
@ -142,11 +116,10 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxProps,
/** /**
* Renders the suggestions panel below the input control * Renders the suggestions panel below the input control
* @param getItemProps downshift getItemProps callback * @param getItemProps downshift getItemProps callback
* @param openMenu downshift openMenu callback
* @param selectedItem downshift selectedItem callback * @param selectedItem downshift selectedItem callback
* @param highlightedIndex downshift highlightedIndex callback * @param highlightedIndex downshift highlightedIndex callback
*/ */
private renderSuggestions(getItemProps, openMenu, selectedItem, highlightedIndex): JSX.Element { private renderSuggestions(getItemProps, selectedItem, highlightedIndex): JSX.Element {
let renderSuggestions: JSX.Element = null; let renderSuggestions: JSX.Element = null;
let suggestions: JSX.Element[] = null; let suggestions: JSX.Element[] = null;
@ -183,27 +156,10 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxProps,
return renderSuggestions; return renderSuggestions;
} }
/** private _setInputValue(inputValue: string) {
* Handler when a user press enter in the search box if (inputValue) {
* @param queryText The query text entered by the user this.setState({
*/ searchInputValue: decodeURIComponent(inputValue),
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));
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"
}); });
} }
} }
@ -216,7 +172,7 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxProps,
if (inputValue && this.props.enableQuerySuggestions) { if (inputValue && this.props.enableQuerySuggestions) {
if (inputValue.length >= this.SUGGESTION_CHAR_COUNT_TRIGGER) { if (inputValue.length >= SUGGESTION_CHAR_COUNT_TRIGGER) {
try { try {
@ -225,7 +181,7 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxProps,
errorMessage: null errorMessage: null
}); });
const suggestions = await this.props.searchDataProvider.suggest(inputValue); const suggestions = await this.props.searchService.suggest(inputValue);
this.setState({ this.setState({
proposedQuerySuggestions: suggestions, proposedQuerySuggestions: suggestions,
@ -284,4 +240,63 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxProps,
private _replaceAt(string, index, replace) { private _replaceAt(string, index, replace) {
return string.substring(0, index) + replace; return string.substring(0, index) + replace;
} }
/**
* Handler when a user enters new keywords
* @param queryText The query text entered by the user
*/
public _onSearch(queryText: string) {
this.setState({
searchInputValue: queryText,
});
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));
const behavior = this.props.openBehavior === PageOpenBehavior.NewTab ? '_blank' : '_self';
window.open(url, behavior);
} else {
// Notify the dynamic data controller
this.props.onSearch(queryText);
}
}
public componentDidMount() {
this._setInputValue(this.props.inputValue);
}
public componentWillReceiveProps(nextProps: ISearchBoxContainerProps) {
this._setInputValue(nextProps.inputValue);
}
public render(): React.ReactElement<ISearchBoxContainerProps> {
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>
);
}
} }

View File

@ -5,11 +5,13 @@ define([], function() {
"SearchBoxEnableQuerySuggestions": "Enable query suggestions", "SearchBoxEnableQuerySuggestions": "Enable query suggestions",
"SearchBoxNewPage": "Search box options", "SearchBoxNewPage": "Search box options",
"SearchBoxSearchInNewPageLabel": "Send the query to a new page", "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", "SearchBoxPageUrlLabel": "Page URL",
"SearchBoxUrlErrorMessage": "Please provide a valid URL.", "SearchBoxUrlErrorMessage": "Please provide a valid URL.",
"SearchBoxSameTabOpenBehavior": "Use the current tab", "SearchBoxSameTabOpenBehavior": "Use the current tab",
"SearchBoxNewTabOpenBehavior": "Open in a new tab", "SearchBoxNewTabOpenBehavior": "Open in a new tab",
"SearchBoxPageOpenBehaviorLabel": "Opening behavior" "SearchBoxPageOpenBehaviorLabel": "Opening behavior",
"SearchBoxDynamicPropertyInputLabel": "Input value",
"UseDynamicDataSourceLabel": "Use a dynamic data source as search query",
"SearchBoxDynamicDataSourceGroupName": "Dynamic data source configuration"
} }
}); });

View File

@ -1,15 +1,15 @@
define([], function() { define([], function() {
return { return {
"SearchInputPlaceholder": "Entrez vos termes de recherche...", "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", "SearchBoxNewPage": "Options de la boîte de recherche",
"SearchBoxSearchInNewPageLabel": "Envoyer la requête sur une nouvelle page", "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", "SearchBoxPageUrlLabel": "URL de la page",
"SearchBoxUrlErrorMessage": "Veuillez spécifier une URL valide", "SearchBoxUrlErrorMessage": "Veuillez spécifier une URL valide",
"SearchBoxSameTabOpenBehavior": "Utiliser l'onglet courant", "SearchBoxSameTabOpenBehavior": "Utiliser l'onglet courant",
"SearchBoxNewTabOpenBehavior": "Ouvrir dans un nouvel onglet", "SearchBoxNewTabOpenBehavior": "Ouvrir dans un nouvel onglet",
"SearchBoxPageOpenBehaviorLabel": "Mode d'ouverture de la page" "SearchBoxPageOpenBehaviorLabel": "Mode d'ouverture de la page",
"SearchBoxDynamicPropertyInputLabel": "Valeur du champ de recherche",
"UseDynamicDataSourceLabel": "Utiliser une source de données dynamique comme requête de recherche",
"SearchBoxDynamicDataSourceGroupName": "Configuration de la source de données dynamique"
} }
}); });

View File

@ -10,6 +10,9 @@ declare interface ISearchBoxWebPartStrings {
SearchBoxSameTabOpenBehavior: string; SearchBoxSameTabOpenBehavior: string;
SearchBoxNewTabOpenBehavior: string; SearchBoxNewTabOpenBehavior: string;
SearchBoxPageOpenBehaviorLabel: string; SearchBoxPageOpenBehaviorLabel: string;
SearchBoxDynamicPropertyInputLabel: string;
UseDynamicDataSourceLabel: string;
SearchBoxDynamicDataSourceGroupName: string;
} }
declare module 'SearchBoxWebPartStrings' { declare module 'SearchBoxWebPartStrings' {

View File

@ -1,3 +1,5 @@
import ResultsLayoutOption from '../../models/ResultsLayoutOption';
export interface ISearchResultsWebPartProps { export interface ISearchResultsWebPartProps {
queryKeywords: string; queryKeywords: string;
queryTemplate: string; queryTemplate: string;
@ -7,9 +9,13 @@ export interface ISearchResultsWebPartProps {
selectedProperties: string; selectedProperties: string;
refiners: string; refiners: string;
showPaging: boolean; showPaging: boolean;
showFileIcon: boolean;
showCreatedDate: boolean;
showResultsCount: boolean; showResultsCount: boolean;
showBlank: boolean; showBlank: boolean;
useSearchBoxQuery: boolean; useSearchBoxQuery: boolean;
selectedLayout: ResultsLayoutOption;
externalTemplateUrl: string;
inlineTemplateText: string;
dynamicDataSourceId: string;
dynamicDataSourcePropertyId: string;
dynamicDataSourceComponentId: string;
} }

View File

@ -17,7 +17,7 @@
"default": "PnP" "default": "PnP"
}, },
"title": { "title": {
"default": "Search Results with Refiners", "fr-fr": "Résulats de recherche avec filtres" "default": "Search Results with Refiners", "fr-fr": "Résultats de recherche"
}, },
"description": { "description": {
"default": "Displays search results with customizable dynamic refiners", "default": "Displays search results with customizable dynamic refiners",
@ -28,13 +28,11 @@
"queryKeywords": "", "queryKeywords": "",
"queryTemplate": "{searchTerms} Path:{Site}", "queryTemplate": "{searchTerms} Path:{Site}",
"refiners": "Created:\"Created Date\",Size:\"Size of the file\"", "refiners": "Created:\"Created Date\",Size:\"Size of the file\"",
"selectedProperties": "Title,Path,Created,Filename,SiteLogo,PreviewUrl,PictureThumbnailURL,ServerRedirectedPreviewURL,ServerRedirectedURL", "selectedProperties": "Title,Path,Created,Filename,SiteLogo,PreviewUrl,PictureThumbnailURL,ServerRedirectedPreviewURL,ServerRedirectedURL,HitHighlightedSummary",
"enableQueryRules": false, "enableQueryRules": false,
"maxResultsCount": 10, "maxResultsCount": 10,
"showFileIcon": true, "showBlank": true,
"showCreatedDate": true, "showResultsCount": true
"showBlank": false,
"showResultsCount": false
} }
} }
] ]

View File

@ -1,174 +1,137 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDom from 'react-dom'; import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library'; import { Text, Log } from '@microsoft/sp-core-library';
import { import {
BaseClientSideWebPart, BaseClientSideWebPart,
PropertyPaneSlider, PropertyPaneSlider,
IPropertyPaneConfiguration, IPropertyPaneConfiguration,
PropertyPaneTextField, PropertyPaneTextField,
PropertyPaneToggle, PropertyPaneToggle,
IEvent PropertyPaneCheckbox,
PropertyPaneChoiceGroup,
IPropertyPaneChoiceGroupOption,
IPropertyPaneField,
IPropertyPaneDropdownOption,
PropertyPaneDropdown
} from '@microsoft/sp-webpart-base'; } from '@microsoft/sp-webpart-base';
import { Environment, EnvironmentType } from '@microsoft/sp-core-library'; import { Environment, EnvironmentType } from '@microsoft/sp-core-library';
import * as strings from 'SearchWebPartStrings'; import * as strings from 'SearchWebPartStrings';
import SearchContainer from "./components/SearchResultsContainer/SearchResultsContainer"; import SearchContainer from './components/SearchResultsContainer/SearchResultsContainer';
import ISearchContainerProps from "./components/SearchResultsContainer/ISearchResultsContainerProps"; import ISearchContainerProps from './components/SearchResultsContainer/ISearchResultsContainerProps';
import { ISearchResultsWebPartProps } from './ISearchResultsWebPartProps'; import { ISearchResultsWebPartProps } from './ISearchResultsWebPartProps';
import ISearchDataProvider from "../dataProviders/SearchDataProvider/ISearchDataProvider"; import ISearchService from '../../services/SearchService/ISearchService';
import MockSearchDataProvider from "../dataProviders/SearchDataProvider/MockSearchDataProvider"; import MockSearchService from '../../services/SearchService/MockSearchService';
import SearchDataProvider from "../dataProviders/SearchDataProvider/SearchDataProvider"; import SearchService from '../../services/SearchService/SearchService';
import ITaxonomyDataProvider from "../dataProviders/TaxonomyProvider/ITaxonomyDataProvider"; import ITaxonomyService from '../../services/TaxonomyService/ITaxonomyService';
import MockTaxonomyDataProvider from "../dataProviders/TaxonomyProvider/MockTaxonomyDataProvider"; import MockTaxonomyService from '../../services/TaxonomyService/MockTaxonomyService';
import TaxonomyProvider from "../dataProviders/TaxonomyProvider/TaxonomyProvider"; import TaxonomyService from '../../services/TaxonomyService/TaxonomyService';
import * as moment from "moment"; import * as moment from 'moment';
import { Placeholder, IPlaceholderProps } from "@pnp/spfx-controls-react/lib/Placeholder"; import { Placeholder, IPlaceholderProps } from '@pnp/spfx-controls-react/lib/Placeholder';
import { PropertyPaneCheckbox } from '@microsoft/sp-webpart-base/lib/propertyPane/propertyPaneFields/propertyPaneCheckBox/PropertyPaneCheckbox'; import { DisplayMode } from '@microsoft/sp-core-library';
import { PropertyPaneHorizontalRule } from '@microsoft/sp-webpart-base/lib/propertyPane/propertyPaneFields/propertyPaneHorizontalRule/PropertyPaneHorizontalRule'; import LocalizationHelper from '../../helpers/LocalizationHelper';
import { UrlUtilities, DisplayMode } from "@microsoft/sp-core-library"; import ResultsLayoutOption from '../../models/ResultsLayoutOption';
import LocalizationHelper from "../common/LocalizationHelper"; import TemplateService from '../../services/TemplateService/TemplateService';
import { UrlHelper } from '../common/UrlHelper'; import { PropertyPaneTextDialog } from '../controls/PropertyPaneTextDialog/PropertyPaneTextDialog';
import { update, isEmpty } from '@microsoft/sp-lodash-subset';
import MockTemplateService from '../../services/TemplateService/MockTemplateService';
import BaseTemplateService from '../../services/TemplateService/BaseTemplateService';
import { IDynamicDataSource } from '@microsoft/sp-dynamic-data';
export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsWebPartProps> { const LOG_SOURCE: string = '[SearchResultsWebPart_{0}]';
private _searchDataProvider: ISearchDataProvider; export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchResultsWebPartProps> {
private _taxonomyDataProvider: ITaxonomyDataProvider;
private _searchService: ISearchService;
private _taxonomyService: ITaxonomyService;
private _templateService: BaseTemplateService;
private _useResultSource: boolean; private _useResultSource: boolean;
private _queryKeywords: string;
private _source: IDynamicDataSource;
private _domElement: HTMLElement;
public constructor() { /**
* Used to be able to unregister dynamic data events if the source is updated
*/
private _lastSourceId: string = undefined;
private _lastPropertyId: string = undefined;
/**
* The template to display at render time
*/
private _templateContentToDisplay: string;
constructor() {
super(); super();
this._parseRefiners = this._parseRefiners.bind(this); this._parseRefiners = this._parseRefiners.bind(this);
this.bindSearchQuery = this.bindSearchQuery.bind(this);
} }
/** /**
* Override the base onInit() implementation to get the persisted properties to initialize data provider. * Resolves the connected data sources
* Useful in the case when the data source comes from an extension,
* the id is regenerated every time the page is refreshed causing the property pane configuration be lost
*/ */
protected onInit(): Promise<void> { private _initDynamicDataSource() {
// Init the moment JS library locale globally if (this.properties.dynamicDataSourceId
const currentLocale = this.context.pageContext.cultureInfo.currentCultureName; && this.properties.dynamicDataSourcePropertyId
moment.locale(currentLocale); && this.properties.dynamicDataSourceComponentId) {
if (Environment.type === EnvironmentType.Local) { this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
this._searchDataProvider = new MockSearchDataProvider(); let sourceId = undefined;
this._taxonomyDataProvider = new MockTaxonomyDataProvider();
if (this._source) {
sourceId = this._source.id;
} else { } else {
// Try to resolve the source and get its id by the name
const lcid = LocalizationHelper.getLocaleId(this.context.pageContext.cultureInfo.currentUICultureName); this._source = this._tryGetSourceByComponentId(this.properties.dynamicDataSourceComponentId);
sourceId = this._source ? this._source.id : undefined;
this._searchDataProvider = new SearchDataProvider(this.context);
this._taxonomyDataProvider = new TaxonomyProvider(this.context, lcid);
} }
this._useResultSource = false; if (sourceId) {
this.context.dynamicDataProvider.registerPropertyChanged(sourceId, this.properties.dynamicDataSourcePropertyId, this.render);
if (this.properties.useSearchBoxQuery) { // Update the property for the property pane
this.properties.dynamicDataSourceId = sourceId;
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
// Check if there is an existing query parameter on loading (only on first load) // If false, means the onInit method is not completed yet so we let it render the web part through the normal process
const queryStringKeywords = UrlHelper.getQueryStringParam("q", window.location.href); if (this.renderedOnce) {
this.render();
}
}
}
}
if (queryStringKeywords) { /**
this.properties.queryKeywords = decodeURIComponent(queryStringKeywords); * Gets a dynamic data source by its component id. The component id doesn't change when the page is refreshed
* @param dataSourceComponentId the component id
*/
private _tryGetSourceByComponentId(dataSourceComponentId: string): IDynamicDataSource {
const resolvedDataSource = this.context.dynamicDataProvider.getAvailableSources()
.filter((item) => {
if (item.metadata.componentId) {
if (item.metadata.componentId.localeCompare(dataSourceComponentId) === 0) {
return item;
}
}
});
if (resolvedDataSource.length > 0 ) {
return resolvedDataSource[0];
} else { } else {
this.properties.queryKeywords = ""; Log.verbose(Text.format(LOG_SOURCE, "_tryGetSourceByComponentId()"), `Unable to find dynamic data source with componentId '${dataSourceComponentId}'`);
return undefined;
} }
} }
// 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 * Determines the group fields for the search settings options inside the property pane
// We don't want subscribe every time this option is changed */
this.context.eventAggregator.subscribeByEventName("search:newQueryKeywords", this.componentId , this.bindSearchQuery); private _getSearchSettingsFields(): IPropertyPaneField<any>[] {
// Sets up search settings fields
return super.onInit(); const searchSettingsFields: IPropertyPaneField<any>[] = [
}
protected get disableReactivePropertyChanges(): boolean {
// Set this to true if you don't want the reactive behavior.
return false;
}
public render(): void {
let renderElement = null;
// Configure the provider before the query according to our needs
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._searchDataProvider,
taxonomyDataProvider: this._taxonomyDataProvider,
queryKeywords: this.properties.queryKeywords,
maxResultsCount: this.properties.maxResultsCount,
resultSourceId: this.properties.resultSourceId,
enableQueryRules: this.properties.enableQueryRules,
selectedProperties: this.properties.selectedProperties ? this.properties.selectedProperties.replace(/\s|,+$/g, '').split(",") : [],
refiners: this._parseRefiners(this.properties.refiners),
showPaging: this.properties.showPaging,
showFileIcon: this.properties.showFileIcon,
showCreatedDate: this.properties.showCreatedDate,
showResultsCount: this.properties.showResultsCount,
showBlank: this.properties.showBlank,
displayMode: this.displayMode
} as ISearchContainerProps
);
const placeholder: React.ReactElement<IPlaceholderProps> = React.createElement(
Placeholder,
{
iconName: strings.PlaceHolderEditLabel,
iconText: strings.PlaceHolderIconText,
description: strings.PlaceHolderDescription,
buttonLabel: strings.PlaceHolderConfigureBtnLabel,
onConfigure: this._setupWebPart.bind(this)
}
);
renderElement = (this.properties.queryKeywords && !this.properties.useSearchBoxQuery) || this.properties.useSearchBoxQuery ? searchContainer : placeholder;
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');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
groups: [
{
groupName: strings.SearchSettingsGroupName,
groupFields: [
PropertyPaneCheckbox("useSearchBoxQuery", {
checked: false,
text: strings.UseSearchBoxQueryLabel,
}),
PropertyPaneTextField('queryKeywords', {
label: strings.SearchQueryKeywordsFieldLabel,
description: strings.SearchQueryKeywordsFieldDescription,
value: this.properties.useSearchBoxQuery ? "" : this.properties.queryKeywords,
multiline: true,
resizable: true,
placeholder: strings.SearchQueryPlaceHolderText,
onGetErrorMessage: this._validateEmptyField.bind(this),
deferredValidationTime: 500,
disabled: this.properties.useSearchBoxQuery
}),
PropertyPaneTextField('queryTemplate', { PropertyPaneTextField('queryTemplate', {
label: strings.QueryTemplateFieldLabel, label: strings.QueryTemplateFieldLabel,
value: this.properties.queryTemplate, value: this.properties.queryTemplate,
@ -204,49 +167,163 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
value: this.properties.refiners, value: this.properties.refiners,
deferredValidationTime: 300, deferredValidationTime: 300,
}), }),
PropertyPaneSlider("maxResultsCount", { PropertyPaneSlider('maxResultsCount', {
label: strings.MaxResultsCount, label: strings.MaxResultsCount,
max: 50, max: 50,
min: 1, min: 1,
showValue: true, showValue: true,
step: 1, step: 1,
value: 50, value: 50,
}),
];
return searchSettingsFields;
}
/**
* Determines the group fields for the search query options inside the property pane
*/
private _getSearchQueryFields(): IPropertyPaneField<any>[] {
// Sets up search query fields
let searchQueryConfigFields: IPropertyPaneField<any>[] = [
PropertyPaneCheckbox('useSearchBoxQuery', {
checked: false,
text: strings.UseSearchBoxQueryLabel,
}) })
] ];
if (this.properties.useSearchBoxQuery) {
const sourceOptions: IPropertyPaneDropdownOption[] =
this.context.dynamicDataProvider.getAvailableSources().map(source => {
return {
key: source.id,
text: source.metadata.title
};
}).filter(item => item.key.localeCompare("PageContext") !== 0);
const selectedSource: string = this.properties.dynamicDataSourceId;
let propertyOptions: IPropertyPaneDropdownOption[] = [];
if (selectedSource) {
const source: IDynamicDataSource = this.context.dynamicDataProvider.tryGetSource(selectedSource);
if (source) {
propertyOptions = source.getPropertyDefinitions().map(prop => {
return {
key: prop.id,
text: prop.title
};
});
}
}
searchQueryConfigFields = searchQueryConfigFields.concat([
PropertyPaneDropdown('dynamicDataSourceId', {
label: "Source",
options: sourceOptions,
selectedKey: this.properties.dynamicDataSourceId,
}),
PropertyPaneDropdown('dynamicDataSourcePropertyId', {
disabled: !this.properties.dynamicDataSourceId,
label: "Source property",
options: propertyOptions,
selectedKey: this.properties.dynamicDataSourcePropertyId
}),
]);
} else {
searchQueryConfigFields.push(
PropertyPaneTextField('queryKeywords', {
label: strings.SearchQueryKeywordsFieldLabel,
description: strings.SearchQueryKeywordsFieldDescription,
value: this.properties.useSearchBoxQuery ? '' : this.properties.queryKeywords,
multiline: true,
resizable: true,
placeholder: strings.SearchQueryPlaceHolderText,
onGetErrorMessage: this._validateEmptyField.bind(this),
deferredValidationTime: 500,
disabled: this.properties.useSearchBoxQuery
})
);
}
return searchQueryConfigFields;
}
/**
* Determines the group fields for styling options inside the property pane
*/
private _getStylingFields(): IPropertyPaneField<any>[] {
// Options for the search results layout
const layoutOptions = [
{
iconProps: {
officeFabricIconFontName: 'List'
}, },
] text: strings.ListLayoutOption,
key: ResultsLayoutOption.List,
}, },
{ {
groups: [ iconProps: {
officeFabricIconFontName: 'Tiles'
},
text: strings.TilesLayoutOption,
key: ResultsLayoutOption.Tiles
},
{ {
groupName: strings.StylingSettingsGroupName, iconProps: {
groupFields: [ officeFabricIconFontName: 'Code'
PropertyPaneToggle("showBlank", { },
text: strings.CustomLayoutOption,
key: ResultsLayoutOption.Custom,
}
] as IPropertyPaneChoiceGroupOption[];
const canEditTemplate = this.properties.externalTemplateUrl && this.properties.selectedLayout === ResultsLayoutOption.Custom ? false : true;
// Sets up styling fields
let stylingFields: IPropertyPaneField<any>[] = [
PropertyPaneToggle('showBlank', {
label: strings.ShowBlankLabel, label: strings.ShowBlankLabel,
checked: this.properties.showBlank, checked: this.properties.showBlank,
}), }),
PropertyPaneToggle("showResultsCount", { PropertyPaneToggle('showResultsCount', {
label: strings.ShowResultsCountLabel, label: strings.ShowResultsCountLabel,
checked: this.properties.showResultsCount, checked: this.properties.showResultsCount,
}), }),
PropertyPaneToggle("showPaging", { PropertyPaneToggle('showPaging', {
label: strings.ShowPagingLabel, label: strings.ShowPagingLabel,
checked: this.properties.showPaging, checked: this.properties.showPaging,
}), }),
PropertyPaneToggle("showFileIcon", { PropertyPaneChoiceGroup('selectedLayout', {
label: strings.ShowFileIconLabel, label:'Results layout',
checked: this.properties.showFileIcon, options: layoutOptions
}), }),
PropertyPaneToggle("showCreatedDate", { new PropertyPaneTextDialog('inlineTemplateText', {
label: strings.ShowCreatedDateLabel, dialogTextFieldValue: this._templateContentToDisplay,
checked: this.properties.showCreatedDate, onPropertyChange: this._onCustomPropertyPaneChange.bind(this),
}), disabled: !canEditTemplate,
] strings: {
cancelButtonText: strings.CancelButtonText,
dialogButtonLabel: strings.DialogButtonLabel,
dialogButtonText: strings.DialogButtonText,
dialogTitle: strings.DialogTitle,
saveButtonText: strings.SaveButtonText
} }
] })
];
// Only show the template external URL for 'Custom' option
if (this.properties.selectedLayout === ResultsLayoutOption.Custom) {
stylingFields.push(PropertyPaneTextField('externalTemplateUrl', {
label: strings.TemplateUrlFieldLabel,
placeholder: strings.TemplateUrlPlaceholder,
deferredValidationTime: 500,
onGetErrorMessage: this._onTemplateUrlChange.bind(this)
}));
} }
]
}; return stylingFields;
} }
/** /**
@ -256,26 +333,23 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
this.context.propertyPane.open(); this.context.propertyPane.open();
} }
/**
* Checks if a field if empty or not
* @param value the value to check
*/
private _validateEmptyField(value: string): string { private _validateEmptyField(value: string): string {
if (!value) { if (!value) {
return strings.EmptyFieldErrorMessage; return strings.EmptyFieldErrorMessage;
} }
return ""; return '';
}
public bindSearchQuery(eventName: string, eventData: IEvent<any>) {
if (this.properties.useSearchBoxQuery) {
if (eventData.data) {
this.properties.queryKeywords = eventData.data;
this.render();
}
}
} }
/**
* Ensures the result source id value is a valid GUID
* @param value the result source id
*/
private validateSourceId(value: string): string { private validateSourceId(value: string): string {
if(value.length > 0) { if(value.length > 0) {
if (!/^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$/.test(value)) { if (!/^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$/.test(value)) {
@ -291,17 +365,21 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
return ''; return '';
} }
/**
* Parses refiners from the property pane value by extracting the refiner managed property and its label in the filter panel.
* @param rawValue the raw value of the refiner
*/
private _parseRefiners(rawValue: string) : { [key: string]: string } { private _parseRefiners(rawValue: string) : { [key: string]: string } {
let refiners = {}; let refiners = {};
// Get each configuration // Get each configuration
let refinerKeyValuePair = rawValue.split(","); let refinerKeyValuePair = rawValue.split(',');
if (refinerKeyValuePair.length > 0) { if (refinerKeyValuePair.length > 0) {
refinerKeyValuePair.map((e) => { refinerKeyValuePair.map((e) => {
const refinerValues = e.split(":"); const refinerValues = e.split(':');
switch (refinerValues.length) { switch (refinerValues.length) {
case 1: case 1:
// Take the same name as the refiner managed property // Take the same name as the refiner managed property
@ -310,7 +388,7 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
case 2: case 2:
// Trim quotes if present // Trim quotes if present
refiners[refinerValues[0]] = refinerValues[1].replace(/^"(.*)"$/, '$1'); refiners[refinerValues[0]] = refinerValues[1].replace(/^'(.*)'$/, '$1');
break; break;
} }
}); });
@ -318,4 +396,284 @@ export default class SearchWebPart extends BaseClientSideWebPart<ISearchResultsW
return refiners; return refiners;
} }
/**
* Get the correct results template content according to the property pane current configuration
* @returns the template content as a string
*/
private async _getTemplateContent(): Promise<void> {
let templateContent = null;
switch (this.properties.selectedLayout) {
case ResultsLayoutOption.List:
templateContent = TemplateService.getListDefaultTemplate();
break;
case ResultsLayoutOption.Tiles:
templateContent = TemplateService.getTilesDefaultTemplate();
break;
case ResultsLayoutOption.Custom:
if (this.properties.externalTemplateUrl) {
templateContent = await this._templateService.getFileContent(this.properties.externalTemplateUrl);
} else {
templateContent = this.properties.inlineTemplateText ? this.properties.inlineTemplateText : TemplateService.getBlankDefaultTemplate();
}
break;
default:
break;
}
this._templateContentToDisplay = templateContent;
}
/**
* Custom handler when a custom property pane field is updated
* @param propertyPath the name of the updated property
* @param newValue the new value for this property
*/
private _onCustomPropertyPaneChange(propertyPath: string, newValue: any): void {
// Stores the new value in web part properties
update(this.properties, propertyPath, (): any => { return newValue; });
// Call the default SPFx handler
this.onPropertyPaneFieldChanged(propertyPath);
// Refreshes the web part manually because custom fields don't update since sp-webpart-base@1.1.1
// https://github.com/SharePoint/sp-dev-docs/issues/594
if (!this.disableReactivePropertyChanges) {
// The render has to be completed before the property pane to refresh to set up the correct property value
// so the property pane field will use the correct value for future edit
this.render();
this.context.propertyPane.refresh();
}
}
/**
* Custom handler when the external template file URL
* @param value the template file URL value
*/
private async _onTemplateUrlChange(value: string): Promise<String> {
try {
// Doesn't raise any error if file is empty (otherwise error message will show on initial load...)
if(isEmpty(value)) {
return '';
}
// Resolves an error if the file isn't a valid .htm or .html file
else if(!TemplateService.isValidTemplateFile(value)) {
return strings.ErrorTemplateExtension;
}
// Resolves an error if the file doesn't answer a simple head request
else {
await this._templateService.ensureFileResolves(value);
return '';
}
} catch (error) {
return Text.format(strings.ErrorTemplateResolve, error);
}
}
/**
* Override the base onInit() implementation to get the persisted properties to initialize data provider.
*/
protected onInit(): Promise<void> {
this._domElement = this.domElement;
// Init the moment JS library locale globally
const currentLocale = this.context.pageContext.cultureInfo.currentUICultureName;
moment.locale(currentLocale);
if (Environment.type === EnvironmentType.Local) {
this._searchService = new MockSearchService();
this._taxonomyService = new MockTaxonomyService();
this._templateService = new MockTemplateService();
} else {
const lcid = LocalizationHelper.getLocaleId(this.context.pageContext.cultureInfo.currentUICultureName);
this._searchService = new SearchService(this.context);
this._taxonomyService = new TaxonomyService(this.context, lcid);
this._templateService = new TemplateService(this.context.spHttpClient);
}
// Configure search query settings
this._useResultSource = false;
// Set the default search results layout
this.properties.selectedLayout = this.properties.selectedLayout ? this.properties.selectedLayout: ResultsLayoutOption.List;
// Make sure the data source will be plugged in correctly when loaded on the page
// Depending of the component loading order, some sources may be unavailable at this time so that's why we use an event listener
this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
return super.onInit();
}
protected get disableReactivePropertyChanges(): boolean {
// Set this to true if you don't want the reactive behavior.
return false;
}
protected get isRenderAsync(): boolean {
return true;
}
protected renderCompleted(): void {
super.renderCompleted();
let renderElement = null;
const searchContainer: React.ReactElement<ISearchContainerProps> = React.createElement(
SearchContainer,
{
searchDataProvider: this._searchService,
taxonomyDataProvider: this._taxonomyService,
queryKeywords: this._queryKeywords,
maxResultsCount: this.properties.maxResultsCount,
resultSourceId: this.properties.resultSourceId,
enableQueryRules: this.properties.enableQueryRules,
selectedProperties: this.properties.selectedProperties ? this.properties.selectedProperties.replace(/\s|,+$/g, '').split(',') : [],
refiners: this._parseRefiners(this.properties.refiners),
showPaging: this.properties.showPaging,
showResultsCount: this.properties.showResultsCount,
showBlank: this.properties.showBlank,
displayMode: this.displayMode,
templateService: this._templateService,
templateContent: this._templateContentToDisplay
} as ISearchContainerProps
);
const placeholder: React.ReactElement<IPlaceholderProps> = React.createElement(
Placeholder,
{
iconName: strings.PlaceHolderEditLabel,
iconText: strings.PlaceHolderIconText,
description: strings.PlaceHolderDescription,
buttonLabel: strings.PlaceHolderConfigureBtnLabel,
onConfigure: this._setupWebPart.bind(this)
}
);
if ((this.properties.queryKeywords && !this.properties.useSearchBoxQuery) ||
(this.properties.useSearchBoxQuery && this.properties.dynamicDataSourcePropertyId)) {
renderElement = searchContainer;
} else {
if (this.displayMode === DisplayMode.Edit) {
renderElement = placeholder;
} else {
renderElement = React.createElement('div', null);
}
}
ReactDom.render(renderElement, this._domElement);
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
groups: [
{
groupName: strings.SearchQuerySettingsGroupName,
groupFields: this._getSearchQueryFields()
},
{
groupName: strings.SearchSettingsGroupName,
groupFields: this._getSearchSettingsFields()
},
],
displayGroupsAsAccordion: true
},
{
groups: [
{
groupName: strings.StylingSettingsGroupName,
groupFields: this._getStylingFields()
}
],
displayGroupsAsAccordion: true
}
]
};
}
public async onPropertyPaneFieldChanged(changedProperty: string) {
if (changedProperty === 'useSearchBoxQuery') {
if (!this.properties.useSearchBoxQuery) {
// Reset source settings if we don't use search query
this.properties.dynamicDataSourceId = undefined;
this.properties.dynamicDataSourcePropertyId = undefined;
this.context.dynamicDataProvider.unregisterAvailableSourcesChanged(this._initDynamicDataSource.bind(this));
}
}
// Detect if the layout has been changed to custom...
if (changedProperty === 'inlineTemplateText') {
// Automatically switch the option to 'Custom' if a default template has been edited
// (meaning the user started from a the list or tiles template)
if (this.properties.inlineTemplateText && this.properties.selectedLayout !== ResultsLayoutOption.Custom) {
this.properties.selectedLayout = ResultsLayoutOption.Custom;
// Reset also the template URL
this.properties.externalTemplateUrl = '';
}
}
if (changedProperty === 'dynamicDataSourceId') {
this._source = this.context.dynamicDataProvider.tryGetSource(this.properties.dynamicDataSourceId);
this.properties.dynamicDataSourcePropertyId = this._source.getPropertyDefinitions()[0].id;
this.properties.dynamicDataSourceComponentId = this._source.metadata.componentId;
// Unregister previous event listeners is the source is updated
if (this._lastSourceId && this._lastPropertyId) {
// Check if the source is still on the page so we can unregister
if (this.context.dynamicDataProvider.tryGetSource(this._lastSourceId)) {
this.context.dynamicDataProvider.unregisterPropertyChanged(this._lastSourceId, this._lastPropertyId, this.render);
}
}
this.context.dynamicDataProvider.registerPropertyChanged(this.properties.dynamicDataSourceId, this.properties.dynamicDataSourcePropertyId, this.render);
this._lastSourceId = this.properties.dynamicDataSourceId;
this._lastPropertyId = this.properties.dynamicDataSourcePropertyId;
}
}
public async render(): Promise<void> {
// Configure the provider before the query according to our needs
this._searchService.resultsCount = this.properties.maxResultsCount;
this._searchService.queryTemplate = this.properties.queryTemplate;
this._searchService.resultSourceId = this.properties.resultSourceId;
this._searchService.enableQueryRules = this.properties.enableQueryRules;
this._queryKeywords = this.properties.queryKeywords;
// If a source is selected, use the value from here
if (this.properties.useSearchBoxQuery) {
if (this.properties.dynamicDataSourceId && this.properties.dynamicDataSourcePropertyId) {
this._queryKeywords = this._source ? this._source.getPropertyValue(this.properties.dynamicDataSourcePropertyId) : this._queryKeywords;
}
}
// Determine the template content to display
// In the case of an external template is selected, the render is done asynchronously waiting for the content to be fetched
await this._getTemplateContent();
this.renderCompleted();
}
} }

View File

@ -1,24 +1,22 @@
import * as React from "react"; import * as React from 'react';
import IFilterPanelProps from "./IFilterPanelProps"; import IFilterPanelProps from './IFilterPanelProps';
import IFilterPanelState from "./IFilterPanelState"; 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 { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel';
import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox';
import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; import { Toggle } from 'office-ui-fabric-react/lib/Toggle';
import * as strings from "SearchWebPartStrings"; import * as strings from 'SearchWebPartStrings';
import { IRefinementResult, IRefinementValue, IRefinementFilter } from "../../../models/ISearchResult"; import { IRefinementValue, IRefinementFilter } from '../../../../models/ISearchResult';
import { Link } from 'office-ui-fabric-react/lib/Link';
import { Label } from 'office-ui-fabric-react/lib/Label'; import { Label } from 'office-ui-fabric-react/lib/Label';
import { Text } from "@microsoft/sp-core-library"; import { Text } from '@microsoft/sp-core-library';
import "../SearchResultsWebPart.scss"; import '../SearchResultsWebPart.scss';
import * as update from "immutability-helper"; import * as update from 'immutability-helper';
import { import {
GroupedList, GroupedList,
IGroup, IGroup,
IGroupDividerProps IGroupDividerProps
} from 'office-ui-fabric-react/lib/components/GroupedList/index'; } from 'office-ui-fabric-react/lib/components/GroupedList/index';
import { Scrollbars } from 'react-custom-scrollbars'; import { Scrollbars } from 'react-custom-scrollbars';
import { ActionButton } from "office-ui-fabric-react"; import { ActionButton } from 'office-ui-fabric-react';
export default class FilterPanel extends React.Component<IFilterPanelProps, IFilterPanelState> { export default class FilterPanel extends React.Component<IFilterPanelProps, IFilterPanelState> {
@ -63,7 +61,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
items.push( items.push(
<div key={i}> <div key={i}>
<div className="filterPanel__filterProperty"> <div className='filterPanel__filterProperty'>
{ {
filter.Values.map((refinementValue: IRefinementValue, j) => { filter.Values.map((refinementValue: IRefinementValue, j) => {
@ -78,7 +76,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
key={j} key={j}
checked={this._isInFilterSelection(currentRefinement)} checked={this._isInFilterSelection(currentRefinement)}
disabled={false} disabled={false}
label={Text.format(refinementValue.RefinementValue + " ({0})", refinementValue.RefinementCount)} label={Text.format(refinementValue.RefinementValue + ' ({0})', refinementValue.RefinementCount)}
onChange={(ev, checked: boolean) => { onChange={(ev, checked: boolean) => {
// Every time we chek/uncheck a filter, a complete new search request is performed with current selected refiners // 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); checked ? this._addFilter(currentRefinement) : this._removeFilter(currentRefinement);
@ -94,8 +92,8 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
const renderSelectedFilters: JSX.Element[] = this.state.selectedFilters.map((filter) => { const renderSelectedFilters: JSX.Element[] = this.state.selectedFilters.map((filter) => {
return ( return (
<Label className="filter"> <Label className='filter'>
<i className="ms-Icon ms-Icon--ClearFilter" onClick={() => { this._removeFilter(filter); }}></i> <i className='ms-Icon ms-Icon--ClearFilter' onClick={() => { this._removeFilter(filter); }}></i>
{filter.Value.RefinementName} {filter.Value.RefinementName}
</Label> </Label>
); );
@ -105,7 +103,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
ref='groupedList' ref='groupedList'
items={items} items={items}
onRenderCell={this._onRenderCell} onRenderCell={this._onRenderCell}
className="filterPanel__body__group" className='filterPanel__body__group'
groupProps={ groupProps={
{ {
onRenderHeader: this._onRenderHeader, onRenderHeader: this._onRenderHeader,
@ -116,20 +114,20 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
return ( return (
<div> <div>
<ActionButton <ActionButton
className="searchWp__filterResultBtn" className='searchWp__filterResultBtn'
iconProps={{ iconName: 'Filter' }} iconProps={{ iconName: 'Filter' }}
text={strings.FilterResultsButtonLabel} text={strings.FilterResultsButtonLabel}
onClick={this._onTogglePanel} onClick={this._onTogglePanel}
/> />
{(this.state.selectedFilters.length > 0) ? {(this.state.selectedFilters.length > 0) ?
<div className="searchWp__selectedFilters"> <div className='searchWp__selectedFilters'>
{renderSelectedFilters} {renderSelectedFilters}
</div> </div>
: null : null
} }
<Panel <Panel
className="filterPanel" className='filterPanel'
isOpen={this.state.showPanel} isOpen={this.state.showPanel}
type={PanelType.smallFixedNear} type={PanelType.smallFixedNear}
isBlocking={false} isBlocking={false}
@ -138,13 +136,13 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
headerText={strings.FilterPanelTitle} headerText={strings.FilterPanelTitle}
closeButtonAriaLabel='Close' closeButtonAriaLabel='Close'
hasCloseButton={true} hasCloseButton={true}
headerClassName="filterPanel__header" headerClassName='filterPanel__header'
onRenderBody={() => { onRenderBody={() => {
if (this.props.availableFilters.length > 0) { if (this.props.availableFilters.length > 0) {
return ( return (
<Scrollbars style={{ height: "100%" }}> <Scrollbars style={{ height: '100%' }}>
<div className="filterPanel__body"> <div className='filterPanel__body'>
<div className="filterPanel__body__allFiltersToggle"> <div className='filterPanel__body__allFiltersToggle'>
<Toggle <Toggle
onText={strings.RemoveAllFiltersLabel} onText={strings.RemoveAllFiltersLabel}
offText={strings.ApplyAllFiltersLabel} offText={strings.ApplyAllFiltersLabel}
@ -160,7 +158,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
); );
} else { } else {
return ( return (
<div className="filterPanel__body"> <div className='filterPanel__body'>
{strings.NoFilterConfiguredLabel} {strings.NoFilterConfiguredLabel}
</div> </div>
); );
@ -173,8 +171,8 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
private _onRenderCell(nestingDepth: number, item: any, itemIndex: number) { private _onRenderCell(nestingDepth: number, item: any, itemIndex: number) {
return ( return (
<div className="ms-Grid-row" data-selection-index={itemIndex}> <div className='ms-Grid-row' data-selection-index={itemIndex}>
<div className="ms-Grid-col ms-u-sm10 ms-u-md10 ms-u-lg10 ms-smPush1 ms-mdPush1 ms-lgPush1"> <div className='ms-Grid-col ms-u-sm10 ms-u-md10 ms-u-lg10 ms-smPush1 ms-mdPush1 ms-lgPush1'>
{item} {item}
</div> </div>
</div> </div>
@ -184,7 +182,7 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
private _onRenderHeader(props: IGroupDividerProps): JSX.Element { private _onRenderHeader(props: IGroupDividerProps): JSX.Element {
return ( return (
<div className="ms-Grid-row" onClick={() => { <div className='ms-Grid-row' onClick={() => {
// Update the index for expanded groups to be able to keep it open after a re-render // Update the index for expanded groups to be able to keep it open after a re-render
const updatedExpandedGroups = const updatedExpandedGroups =
@ -198,13 +196,13 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
props.onToggleCollapse(props.group); props.onToggleCollapse(props.group);
}}> }}>
<div className="ms-Grid-col ms-u-sm1 ms-u-md1 ms-u-lg1"> <div className='ms-Grid-col ms-u-sm1 ms-u-md1 ms-u-lg1'>
<div className="header-icon"> <div className='header-icon'>
<i className={props.group.isCollapsed ? "ms-Icon ms-Icon--ChevronDown" : "ms-Icon ms-Icon--ChevronUp"}></i> <i className={props.group.isCollapsed ? 'ms-Icon ms-Icon--ChevronDown' : 'ms-Icon ms-Icon--ChevronUp'}></i>
</div> </div>
</div> </div>
<div className="ms-Grid-col ms-u-sm10 ms-u-md10 ms-u-lg10"> <div className='ms-Grid-col ms-u-sm10 ms-u-md10 ms-u-lg10'>
<div className="ms-font-l">{props.group.name}</div> <div className='ms-font-l'>{props.group.name}</div>
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import { IRefinementResult } from "../../../models/ISearchResult"; import { IRefinementResult } from '../../../../models/ISearchResult';
import RefinementFilterOperationCallback from "../../../models/RefinementValueOperationCallback"; import RefinementFilterOperationCallback from '../../../../models/RefinementValueOperationCallback';
interface IFilterPanelProps { interface IFilterPanelProps {
availableFilters: IRefinementResult[]; availableFilters: IRefinementResult[];

View File

@ -1,4 +1,4 @@
import { IRefinementFilter } from "../../../models/ISearchResult"; import { IRefinementFilter } from '../../../../models/ISearchResult';
interface IFilterPanelState { interface IFilterPanelState {
showPanel?: boolean; showPanel?: boolean;

View File

@ -0,0 +1,13 @@
import { ISearchResult } from '../../../../models/ISearchResult';
/**
* Handlebars template context for search results
*/
interface ISearchResultsTemplateContext {
items: ISearchResult[];
totalRows: number;
keywords: string;
showResultsCount: boolean;
}
export default ISearchResultsTemplateContext;

View File

@ -0,0 +1,22 @@
import ISearchResultsTemplateContext from './ISearchResultsTemplateContext';
import TemplateService from '../../../../services/TemplateService/TemplateService';
interface ISearchResultsTemplateProps {
/**
* The template helper instance
*/
templateService: TemplateService;
/**
* The template context
*/
templateContext: ISearchResultsTemplateContext;
/**
* The Handlebars raw template content for a single item
*/
templateContent: string;
}
export default ISearchResultsTemplateProps;

View File

@ -0,0 +1,5 @@
interface ISearchResultsTemplateState {
processedTemplate: string;
}
export default ISearchResultsTemplateState;

View File

@ -0,0 +1,55 @@
.template_root {
@import '~office-ui-fabric/dist/sass/Fabric.scss';
@import '~office-ui-fabric/dist/components/Label/Label.scss';
@import '~office-ui-fabric/dist/components/List/List.scss';
@import '~office-ui-fabric/dist/components/ListItem/ListItem.scss';
.template_defaultList {
.template_icon {
background-position: top;
background-repeat: no-repeat;
}
strong {
color: "[theme: themePrimary]"
}
}
.template_defaultCard {
.singleCard {
margin: 10px;
border: 1px solid #eaeaea;
min-height: 200px;
.previewImg {
width: 100%;
height: 111px;
background-size: cover;
background-color: #eaeaea;
position: relative;
border-bottom: 1px solid #eaeaea;
}
.cardInfo {
padding-left: 10px;
padding-right: 10px;
}
.cardFileIcon {
left: 10px;
bottom: 8px;
position: absolute;
}
}
.singleCard:hover {
border-color: #c8c8c8;
cursor: pointer;
}
}
.template_resultCount {
padding-left: 10px;
margin-bottom: 10px;
}
}

View File

@ -0,0 +1,40 @@
import React = require('react');
import ISearchResultsTemplateProps from './ISearchResultsTemplateProps';
import ISearchResultsTemplateState from './ISearchResultsTemplateState';
import './SearchResultsTemplate.scss';
export default class SearchResultsTemplate extends React.Component<ISearchResultsTemplateProps, ISearchResultsTemplateState> {
constructor() {
super();
this.state = {
processedTemplate: null
};
}
public render() {
return <div dangerouslySetInnerHTML={{ __html: this.state.processedTemplate }}></div>;
}
public componentDidMount() {
this._updateTemplate(this.props);
}
public componentWillReceiveProps(nextProps: ISearchResultsTemplateProps) {
this._updateTemplate(nextProps);
}
private async _updateTemplate(props: ISearchResultsTemplateProps): Promise<void> {
let templateContent = props.templateContent;
// Process the Handlebars template
const template = this.props.templateService.processTemplate(props.templateContext, templateContent);
this.setState({
processedTemplate: template
});
}
}

View File

@ -1,7 +1,6 @@
import * as React from "react"; import * as React from 'react';
import IPagingProps from "./IPagingProps"; import IPagingProps from './IPagingProps';
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button'; import Pagination from 'react-js-pagination';
import Pagination from "react-js-pagination";
export default class Paging extends React.Component<IPagingProps, null> { export default class Paging extends React.Component<IPagingProps, null> {
@ -14,15 +13,15 @@ export default class Paging extends React.Component<IPagingProps, null> {
public render(): React.ReactElement<IPagingProps> { public render(): React.ReactElement<IPagingProps> {
return( return(
<div className="searchWp__paginationContainer"> <div className='searchWp__paginationContainer'>
<div className="searchWp__paginationContainer__pagination"> <div className='searchWp__paginationContainer__pagination'>
<Pagination <Pagination
activePage={this.props.currentPage} activePage={this.props.currentPage}
firstPageText={<i className="ms-Icon ms-Icon--DoubleChevronLeft" aria-hidden="true"></i>} firstPageText={<i className='ms-Icon ms-Icon--DoubleChevronLeft' aria-hidden='true'></i>}
lastPageText={<i className="ms-Icon ms-Icon--DoubleChevronRight" aria-hidden="true"></i>} lastPageText={<i className='ms-Icon ms-Icon--DoubleChevronRight' aria-hidden='true'></i>}
prevPageText={<i className="ms-Icon ms-Icon--ChevronLeft" aria-hidden="true"></i>} prevPageText={<i className='ms-Icon ms-Icon--ChevronLeft' aria-hidden='true'></i>}
nextPageText={<i className="ms-Icon ms-Icon--ChevronRight" aria-hidden="true"></i>} nextPageText={<i className='ms-Icon ms-Icon--ChevronRight' aria-hidden='true'></i>}
activeLinkClass={ "active" } activeLinkClass={ 'active' }
itemsCountPerPage={ this.props.itemsCountPerPage } itemsCountPerPage={ this.props.itemsCountPerPage }
totalItemsCount={ this.props.totalItems } totalItemsCount={ this.props.totalItems }
pageRangeDisplayed={5} pageRangeDisplayed={5}

View File

@ -1,18 +1,19 @@
import ISearchDataProvider from "../../../dataProviders/SearchDataProvider/ISearchDataProvider"; import ISearchService from '../../../../services/SearchService/ISearchService';
import ITaxonomyDataProvider from "../../../dataProviders/TaxonomyProvider/ITaxonomyDataProvider"; import ITaxonomyService from '../../../../services/TaxonomyService/ITaxonomyService';
import { DisplayMode } from "@microsoft/sp-core-library"; import { DisplayMode } from '@microsoft/sp-core-library';
import TemplateService from '../../../../services/TemplateService/TemplateService';
interface ISearchResultsContainerProps { interface ISearchResultsContainerProps {
/** /**
* The search data provider instance * The search data provider instance
*/ */
searchDataProvider: ISearchDataProvider; searchDataProvider: ISearchService;
/** /**
* The taxonomy data provider instance * The taxonomy data provider instance
*/ */
taxonomyDataProvider: ITaxonomyDataProvider; taxonomyDataProvider: ITaxonomyService;
/** /**
* The search query keywords * The search query keywords
@ -49,16 +50,6 @@ interface ISearchResultsContainerProps {
*/ */
showPaging: boolean; 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 * Show the result count and entered keywords
*/ */
@ -73,6 +64,16 @@ interface ISearchResultsContainerProps {
* The current display mode of Web Part * The current display mode of Web Part
*/ */
displayMode: DisplayMode; displayMode: DisplayMode;
/**
* The template helper instance
*/
templateService: TemplateService;
/**
* The template raw content to display
*/
templateContent: string;
} }
export default ISearchResultsContainerProps; export default ISearchResultsContainerProps;

View File

@ -1,4 +1,4 @@
import { ISearchResults, IRefinementFilter, IRefinementResult } from "../../../models/ISearchResult"; import { ISearchResults, IRefinementFilter, IRefinementResult } from '../../../../models/ISearchResult';
interface ISearchResultsContainerState { interface ISearchResultsContainerState {

View File

@ -1,23 +1,23 @@
import * as React from "react"; import * as React from 'react';
import ISearchContainerProps from "./ISearchResultsContainerProps"; import ISearchContainerProps from './ISearchResultsContainerProps';
import ISearchContainerState from "./ISearchResultsContainerState"; import ISearchContainerState from './ISearchResultsContainerState';
import { MessageBar, MessageBarType } from "office-ui-fabric-react/lib/MessageBar"; import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import { Spinner, SpinnerSize } from "office-ui-fabric-react/lib/Spinner"; import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { Logger, LogLevel } from "@pnp/logging"; import { Logger, LogLevel } from '@pnp/logging';
import * as strings from "SearchWebPartStrings"; import * as strings from 'SearchWebPartStrings';
import { ISearchResults, IRefinementFilter, IRefinementValue, IRefinementResult } from "../../../models/ISearchResult"; import { IRefinementFilter, IRefinementValue, IRefinementResult } from '../../../../models/ISearchResult';
import TilesList from "../TilesList/TilesList"; import '../SearchResultsWebPart.scss';
import "../SearchResultsWebPart.scss"; import FilterPanel from '../FilterPanel/FilterPanel';
import FilterPanel from "../FilterPanel/FilterPanel"; import Paging from '../Paging/Paging';
import Paging from "../Paging/Paging"; import { Overlay } from 'office-ui-fabric-react/lib/Overlay';
import { Overlay } from "office-ui-fabric-react/lib/Overlay"; import { DisplayMode } from '@microsoft/sp-core-library';
import { Label } from "office-ui-fabric-react"; import SearchResultsTemplate from '../Layouts/SearchResultsTemplate';
import { Text, DisplayMode } from '@microsoft/sp-core-library';
export default class SearchResultsContainer extends React.Component<ISearchContainerProps, ISearchContainerState> { export default class SearchResultsContainer extends React.Component<ISearchContainerProps, ISearchContainerState> {
public constructor(props) { public constructor(props) {
super(props); super(props);
// Set the initial state // Set the initial state
this.state = { this.state = {
results: { results: {
@ -30,9 +30,9 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
currentPage: 1, currentPage: 1,
areResultsLoading: false, areResultsLoading: false,
isComponentLoading: true, isComponentLoading: true,
errorMessage: "", errorMessage: '',
hasError: false, hasError: false,
lastQuery: "" lastQuery: ''
}; };
this._onUpdateFilters = this._onUpdateFilters.bind(this); this._onUpdateFilters = this._onUpdateFilters.bind(this);
@ -49,21 +49,15 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
let renderWpContent: JSX.Element = null; let renderWpContent: JSX.Element = null;
let renderOverlay: JSX.Element = null; let renderOverlay: JSX.Element = null;
let renderCount: JSX.Element = null;
if (!isComponentLoading && areResultsLoading) { if (!isComponentLoading && areResultsLoading) {
renderOverlay = <div> renderOverlay = <div>
<Overlay isDarkThemed={false} className="overlay"> <Overlay isDarkThemed={false} className='overlay'>
<Spinner size={SpinnerSize.medium} /> <Spinner size={SpinnerSize.medium} />
</Overlay> </Overlay>
</div>; </div>;
} }
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) { if (isComponentLoading) {
renderWpContent = <Spinner size={SpinnerSize.large} label={strings.LoadingMessage} />; renderWpContent = <Spinner size={SpinnerSize.large} label={strings.LoadingMessage} />;
} else { } else {
@ -79,8 +73,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
renderWpContent = renderWpContent =
<div> <div>
<FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={ this.props.refiners } /> <FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={ this.props.refiners } />
{ renderCount } <div className='searchWp__noresult'>{strings.NoResultMessage}</div>
<div className="searchWp__noresult">{strings.NoResultMessage}</div>
</div>; </div>;
} else { } else {
if (this.props.displayMode === DisplayMode.Edit) { if (this.props.displayMode === DisplayMode.Edit) {
@ -92,9 +85,19 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
<div> <div>
<FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={ this.props.refiners }/> <FilterPanel availableFilters={this.state.availableFilters} onUpdateFilters={this._onUpdateFilters} refinersConfiguration={ this.props.refiners }/>
{ renderCount }
{ renderOverlay } { renderOverlay }
<TilesList items={items.RelevantResults} showFileIcon={this.props.showFileIcon} showCreatedDate={this.props.showCreatedDate} /> <SearchResultsTemplate
templateService={ this.props.templateService }
templateContent={ this.props.templateContent }
templateContext={
{
items: this.state.results.RelevantResults,
totalRows: this.state.resultCount,
keywords: this.props.queryKeywords,
showResultsCount: this.props.showResultsCount
}
}
/>
{this.props.showPaging ? {this.props.showPaging ?
<Paging <Paging
totalItems={items.TotalRows} totalItems={items.TotalRows}
@ -109,7 +112,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
} }
return ( return (
<div className="searchWp"> <div className='searchWp'>
{ renderWpContent } { renderWpContent }
</div> </div>
); );
@ -127,7 +130,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
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); const localizedFilters = await this._getLocalizedFilters(searchResults.RefinementResults);
@ -146,7 +149,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
} catch (error) { } catch (error) {
Logger.write("[SearchContainer._componentDidMount()]: Error: " + error, LogLevel.Error); Logger.write('[SearchContainer._componentDidMount()]: Error: ' + error, LogLevel.Error);
this.setState({ this.setState({
areResultsLoading: false, areResultsLoading: false,
@ -172,9 +175,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
if (JSON.stringify(this.props.refiners) !== JSON.stringify(nextProps.refiners) if (JSON.stringify(this.props.refiners) !== JSON.stringify(nextProps.refiners)
|| this.props.maxResultsCount !== nextProps.maxResultsCount || this.props.maxResultsCount !== nextProps.maxResultsCount
|| this.state.lastQuery !== query || this.state.lastQuery !== query
|| this.props.showFileIcon !== nextProps.showFileIcon
|| this.props.resultSourceId !== nextProps.resultSourceId || this.props.resultSourceId !== nextProps.resultSourceId
|| this.props.showCreatedDate !== nextProps.showCreatedDate
|| this.props.queryKeywords !== nextProps.queryKeywords || this.props.queryKeywords !== nextProps.queryKeywords
|| this.props.enableQueryRules !== nextProps.enableQueryRules) { || this.props.enableQueryRules !== nextProps.enableQueryRules) {
@ -189,7 +190,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
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 // We reset the page number and refinement filters
const searchResults = await this.props.searchDataProvider.search(nextProps.queryKeywords, refinerManagedProperties, [], 1); const searchResults = await this.props.searchDataProvider.search(nextProps.queryKeywords, refinerManagedProperties, [], 1);
@ -205,7 +206,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
} catch (error) { } catch (error) {
Logger.write("[SearchContainer._componentWillReceiveProps()]: Error: " + error, LogLevel.Error); Logger.write('[SearchContainer._componentWillReceiveProps()]: Error: ' + error, LogLevel.Error);
this.setState({ this.setState({
areResultsLoading: false, areResultsLoading: false,
@ -221,6 +222,22 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
isComponentLoading: false, isComponentLoading: false,
}); });
} }
} else {
// Refresh the template without making a new search query because we don't need to
if (this.props.templateContent !== nextProps.templateContent ||
this.props.showResultsCount !== nextProps.showResultsCount) {
// Reset template errors if it has
if (this.state.hasError) {
this.setState({
hasError: false,
});
} else {
// We don't use a state variable for the template since it is passed from props
// so we force a re render to apply the new template
this.forceUpdate();
}
}
} }
} }
@ -237,7 +254,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
areResultsLoading: true, areResultsLoading: true,
}); });
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, newFilters, 1); const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, refinerManagedProperties, newFilters, 1);
@ -258,7 +275,7 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
areResultsLoading: true, areResultsLoading: true,
}); });
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, pageNumber); const searchResults = await this.props.searchDataProvider.search(this.props.queryKeywords, refinerManagedProperties, this.state.selectedFilters, pageNumber);

View File

@ -1,50 +1,5 @@
.searchWp { .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: "[theme: themePrimary]";
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 { &__noresult {
padding:10px; padding:10px;
text-align: center; text-align: center;
@ -126,10 +81,6 @@
} }
} }
} }
&__count {
padding: 10px;
}
} }
.filterPanel { .filterPanel {

View File

@ -1,9 +0,0 @@
import { ISearchResult } from "../../../models/ISearchResult";
interface ITileProps {
item: ISearchResult;
showFileIcon: boolean;
showCreatedDate: boolean;
}
export default ITileProps;

View File

@ -1,9 +0,0 @@
import { ISearchResult } from "../../../models/ISearchResult";
interface ITilesListViewProps {
items?: ISearchResult[];
showFileIcon: boolean;
showCreatedDate: boolean;
}
export default ITilesListViewProps;

View File

@ -1,58 +0,0 @@
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 { isEmpty } from '@microsoft/sp-lodash-subset';
import "../SearchResultsWebPart.scss";
const PREVIEW_IMAGE_WIDTH: number = 204;
const PREVIEW_IMAGE_HEIGHT: number = 111;
export default class Tile extends React.Component<ITileProps, null> {
public render() {
const item = this.props.item;
let previewSrc = "";
if (!isEmpty(item.SiteLogo)) previewSrc = item.SiteLogo;
else if (!isEmpty(item.PreviewUrl)) previewSrc = item.PreviewUrl;
else if (!isEmpty(item.PictureThumbnailURL)) previewSrc = item.PictureThumbnailURL;
else if (!isEmpty(item.ServerRedirectedPreviewURL)) previewSrc = item.ServerRedirectedPreviewURL;
let iconSrc = this.props.showFileIcon ? item.iconSrc : "";
let previewProps: IDocumentCardPreviewProps = {
previewImages: [
{
url: item.ServerRedirectedURL ? item.ServerRedirectedURL : item.Path,
previewImageSrc: previewSrc,
iconSrc: iconSrc,
imageFit: ImageFit.cover,
height: PREVIEW_IMAGE_HEIGHT,
}
],
};
return (
<DocumentCard onClickHref={item.ServerRedirectedURL ? item.ServerRedirectedURL : item.Path} className="searchWp__resultCard">
<div className="searchWp__tile__iconContainer" style={{ "height": PREVIEW_IMAGE_HEIGHT }}>
<DocumentCardPreview { ...previewProps } />
</div>
<DocumentCardTitle title={item.Title} shouldTruncate={false} />
<div className="searchWp__tile__footer" hidden={!this.props.showCreatedDate}>
<span>{moment(item.Created).isValid() ? moment(item.Created).format("L") : null}</span>
</div>
</DocumentCard>
);
}
}

View File

@ -1,61 +0,0 @@
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 "../SearchResultsWebPart.scss";
const ROWS_PER_PAGE = 3;
const MAX_ROW_HEIGHT = 300;
export default class TilesList extends React.Component<ITilesListViewProps, null> {
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 (
<List
items={items}
getItemCountForPage={this._getItemCountForPage}
getPageHeight={this._getPageHeight}
renderedWindowsAhead={4}
className="searchWp__list"
onRenderCell={(item, index) => (
<div className="searchWp__tile"
style={{
width: (100 / this._columnCount) + '%',
}}>
<Tile key={index} item={item} showFileIcon={this.props.showFileIcon} showCreatedDate={this.props.showCreatedDate} />
</div>
)} />
);
}
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;
}
}

View File

@ -1,5 +1,6 @@
define([], function() { define([], function() {
return { return {
"SearchQuerySettingsGroupName": "Search query configuration",
"SearchSettingsGroupName": "Search settings", "SearchSettingsGroupName": "Search settings",
"SearchQueryKeywordsFieldLabel": "Search query keywords", "SearchQueryKeywordsFieldLabel": "Search query keywords",
"QueryTemplateFieldLabel": "Query template", "QueryTemplateFieldLabel": "Query template",
@ -14,8 +15,6 @@ define([], function() {
"ApplyAllFiltersLabel": "Apply all filters", "ApplyAllFiltersLabel": "Apply all filters",
"RemoveAllFiltersLabel": "Remove all filters", "RemoveAllFiltersLabel": "Remove all filters",
"ShowPagingLabel": "Show paging", "ShowPagingLabel": "Show paging",
"ShowFileIconLabel": "Show file icons",
"ShowCreatedDateLabel": "Show created date",
"ShowResultsCountLabel": "Show results count", "ShowResultsCountLabel": "Show results count",
"ShowBlankLabel": "Show blank if no result", "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.", "ShowBlankEditInfoMessage": "No result returned for this query. This Web Part will remain blank in display mode according to parameters.",
@ -28,12 +27,25 @@ define([], function() {
"PlaceHolderDescription": "This component displays search results with paging and customizable refinement panel", "PlaceHolderDescription": "This component displays search results with paging and customizable refinement panel",
"ResultSourceIdLabel": "Result Source Identifier", "ResultSourceIdLabel": "Result Source Identifier",
"InvalidResultSourceIdMessage": "Invalid identifier", "InvalidResultSourceIdMessage": "Invalid identifier",
"UseSearchBoxQueryLabel": "Use search box query", "UseSearchBoxQueryLabel": "Use a dynamic data source as search query",
"EnableQueryRulesLabel": "Enable query rules", "EnableQueryRulesLabel": "Enable query rules",
"StylingSettingsGroupName": "Styling options", "StylingSettingsGroupName": "Styling options",
"RefinersFieldDescription": "Specifies managed properties used as refiners (ordered comma-separated list). You can specify the label by using the following format <Managed Property Name>:\"My friendly name\"", "RefinersFieldDescription": "Specifies managed properties used as refiners (ordered comma-separated list). You can specify the label by using the following format <Managed Property Name>:\"My friendly name\"",
"SelectedPropertiesFieldDescription": "Speficies the properties to retrieve from the search results.", "SelectedPropertiesFieldDescription": "Speficies the properties to retrieve from the search results.",
"SearchQueryKeywordsFieldDescription": "Use pre-defined search query keywords to retrieve a static set of results.", "SearchQueryKeywordsFieldDescription": "Use pre-defined search query keywords to retrieve a static set of results.",
"CountMessage": "<b>{0}</b> results for '<em>{1}</em>'" "CountMessageLong": "<b>{0}</b> results for '<em>{1}</em>'",
"CountMessageShort": "<b>{0}</b> results",
"CancelButtonText": "Cancel",
"DialogButtonLabel": "Styles",
"DialogButtonText": "Edit template",
"DialogTitle": "Edit results template",
"SaveButtonText": "Save",
"ListLayoutOption": "List",
"TilesLayoutOption": "Tiles",
"CustomLayoutOption": "Custom",
"TemplateUrlFieldLabel": "Use an external template URL",
"TemplateUrlPlaceholder": "https://myfile.html",
"ErrorTemplateExtension": "The template must be a valid .htm or .html file",
"ErrorTemplateResolve": "Unable to resolve the specified template. Error details: '{0}'",
} }
}); });

View File

@ -1,5 +1,6 @@
define([], function() { define([], function() {
return { return {
"SearchQuerySettingsGroupName": "Configuration de la requête de recherche",
"SearchSettingsGroupName": "Paramètres de recherche", "SearchSettingsGroupName": "Paramètres de recherche",
"SearchQueryKeywordsFieldLabel": "Mots clés de recherche", "SearchQueryKeywordsFieldLabel": "Mots clés de recherche",
"QueryTemplateFieldLabel": "Modèle de requête", "QueryTemplateFieldLabel": "Modèle de requête",
@ -14,8 +15,6 @@ define([], function() {
"ApplyAllFiltersLabel": "Appliquer tous les filters", "ApplyAllFiltersLabel": "Appliquer tous les filters",
"RemoveAllFiltersLabel": "Supprimer tous les filtres", "RemoveAllFiltersLabel": "Supprimer tous les filtres",
"ShowPagingLabel": "Afficher la pagination", "ShowPagingLabel": "Afficher la pagination",
"ShowFileIconLabel": "Afficher les icônes de fichier",
"ShowCreatedDateLabel": "Afficher la date de création",
"ShowResultsCountLabel": "Afficher le nombre de résultats", "ShowResultsCountLabel": "Afficher le nombre de résultats",
"ShowBlankLabel": "Ne rien afficher si aucun résultat", "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.", "ShowBlankEditInfoMessage": "Aucun résultat pour cette requête. Ce composant Web Part restera vide en mode affichage conformément aux paramètres.",
@ -28,12 +27,25 @@ define([], function() {
"PlaceHolderDescription": "Ce composant affiche une liste de résulats de recherche avec pagination et des filtres configurables", "PlaceHolderDescription": "Ce composant affiche une liste de résulats de recherche avec pagination et des filtres configurables",
"ResultSourceIdLabel": "Identifiant de l'origine de résultats", "ResultSourceIdLabel": "Identifiant de l'origine de résultats",
"InvalidResultSourceIdMessage": "Identifiant invalide", "InvalidResultSourceIdMessage": "Identifiant invalide",
"UseSearchBoxQueryLabel": "Utiliser la requête de la boîte de recherche", "UseSearchBoxQueryLabel": "Utiliser une source de données dynamique comme requête de recherche",
"EnableQueryRulesLabel": "Activer les règles de requête", "EnableQueryRulesLabel": "Activer les règles de requête",
"StylingSettingsGroupName": "Options d'affichage", "StylingSettingsGroupName": "Options d'affichage",
"RefinersFieldDescription": "Propriétés gerées à utiliser comme filtres (liste ordonnée séparée par une virgule). Vous pouvez spécifier un label personnalisé en utilisant le format suivant <Nom de la propriété gérée>:\"Nom convivial\"", "RefinersFieldDescription": "Propriétés gerées à utiliser comme filtres (liste ordonnée séparée par une virgule). Vous pouvez spécifier un label personnalisé en utilisant le format suivant <Nom de la propriété gérée>:\"Nom convivial\"",
"SelectedPropertiesFieldDescription": "Propriétés à récupérer des résulats de recherche.", "SelectedPropertiesFieldDescription": "Propriétés à récupérer des résulats de recherche.",
"SearchQueryKeywordsFieldDescription": "Utilisez une requête de recherche prédéfinie pour obtenir un ensemble de résultats statique.", "SearchQueryKeywordsFieldDescription": "Utilisez une requête de recherche prédéfinie pour obtenir un ensemble de résultats statique.",
"CountMessage": "<b>{0}</b> résultats pour '<em>{1}</em>'" "CountMessageLong": "<b>{0}</b> résultats pour '<em>{1}</em>'",
"CountMessageShort": "<b>{0}</b> résultats",
"CancelButtonText": "Annuler",
"DialogButtonLabel": "Styles",
"DialogButtonText": "Éditer le modèle",
"DialogTitle": "Éditer le modèle de résulat",
"SaveButtonText": "Enregistrer",
"ListLayoutOption": "Liste",
"TilesLayoutOption": "Tuiles",
"CustomLayoutOption": "Personnalisé",
"TemplateUrlFieldLabel": "Utiliser un fichier modèle externe",
"TemplateUrlPlaceholder": "https://myfile.html",
"ErrorTemplateExtension": "Le file modèle doit être un fichier .htm ou .html valide",
"ErrorTemplateResolve": "Impossible de résoudre le fichier. Détails: '{0}'",
} }
}); });

View File

@ -1,4 +1,5 @@
declare interface ISearchWebPartStrings { declare interface ISearchWebPartStrings {
SearchQuerySettingsGroupName: string;
SearchSettingsGroupName: string; SearchSettingsGroupName: string;
SearchQueryKeywordsFieldLabel: string; SearchQueryKeywordsFieldLabel: string;
SearchQueryKeywordsFieldDescription: string; SearchQueryKeywordsFieldDescription: string;
@ -16,8 +17,6 @@ declare interface ISearchWebPartStrings {
ApplyAllFiltersLabel: string; ApplyAllFiltersLabel: string;
RemoveAllFiltersLabel: string; RemoveAllFiltersLabel: string;
ShowPagingLabel: string; ShowPagingLabel: string;
ShowFileIconLabel: string;
ShowCreatedDateLabel: string;
ShowResultsCountLabel: string; ShowResultsCountLabel: string;
ShowBlankLabel: string; ShowBlankLabel: string;
ShowBlankEditInfoMessage: string; ShowBlankEditInfoMessage: string;
@ -33,7 +32,20 @@ declare interface ISearchWebPartStrings {
UseSearchBoxQueryLabel: string; UseSearchBoxQueryLabel: string;
EnableQueryRulesLabel: string; EnableQueryRulesLabel: string;
StylingSettingsGroupName: string; StylingSettingsGroupName: string;
CountMessage: string; CountMessageShort: string;
CountMessageLong: string;
CancelButtonText: string;
DialogButtonLabel: string;
DialogButtonText: string;
DialogTitle: string;
SaveButtonText: string;
ListLayoutOption: string;
TilesLayoutOption: string;
CustomLayoutOption: string;
TemplateUrlFieldLabel: string;
TemplateUrlPlaceholder: string;
ErrorTemplateExtension: string;
ErrorTemplateResolve: string;
} }
declare module 'SearchWebPartStrings' { declare module 'SearchWebPartStrings' {

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1 @@
/// <reference path="@ms/odsp.d.ts" />

View File

@ -0,0 +1 @@
/// <reference path="@ms/odsp.d.ts" />

16948
samples/react-todo-basic/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff