Recently, I’ve submitted a SPFx Web Part sample showing how to build a dynamic search experience using Office UI fabric components and SharePoint search REST API. This sample comes directly from a real intranet project within SharePoint Online.
Why you would you like to do this?
Well, if you’re currently implementing a new intranet using SharePoint communication sites, you’ve probably noticed you can’t customize the default search experience, for instance, adding your own refiners:
It can be very frustrating, especially if you’ve built a nice information architecture you want to take advantage from via refiners. Also, you can’t even change the target result page for the global search box via search settings (it has no effect). The solution is for now to use either a classic search page with display templates (not for me anymore thanks) or build your own search experience with SPFx. To embrace the future ;), I chose the second option. However, in this post, I won’t talk about the integration of a search box in a custom header via SPFx extensions but I will focus on the search, paging and refinement experience with a static search query.
The full code sample can be retrieved from this repository:
This sample is totally generic and can be used without any additional customization in your solution. Here is a quick demo (click on the image to start the animation):
Web Part configuration
The following settings are available in the Web Part property pane:
Parameter
Description
Search query
The base search query in KQL format.
Query template
The query template in KQL format. You can use search query variables. See this post to know which ones are allowed.
Selected properties
The search managed properties to retrieve. Then you can use these properties in the code like this (item.<managedpropertyname>):
1234567891011 | return ( <DocumentCard onClickHref={ <strong>item.ServerRedirectedURL</strong> ? <strong>item.ServerRedirectedURL</strong> : <strong>item.Path</strong> } className=”searchWp__resultCard”> <div className=”searchWp__tile__iconContainer” style={{ “height”: PREVIEW_IMAGE_HEIGHT }}> <DocumentCardPreview { …previewProps } /> </div> <DocumentCardTitle title={ <strong>item.Title</strong> } shouldTruncate={ false } /> <div className=”searchWp__tile__footer”> <span>{ moment(<strong>item.Created</strong>).isValid() ? moment(<strong>item.Created</strong>).format(“L”): null }</span> </div> </DocumentCard>); |
Refiners
The search managed properties to use as refiners. Make sure these are refinables. With SharePoint Online, you have to reuse the default ones to do so (RefinableStringXX etc.).
The order is the same as they will appear in the refnement panel.
Number of items to retrieve per page
Quite explicit. The paging behavior is done directly by the search API (See the SearchDataProvider.ts file), not by the code on post-render.
Show paging
Indicates whether or not the component should show the paging control at the bottom.
Implementation
Overall approach
This sample uses the React container component approach directly inspired by the PnP react-todo-basic sample. This approach is pretty simple and can be resumed as follow:
A container does data fetching and then renders its corresponding sub-component. That’s it.
From a personal point of view, I always use this pattern to make my code cleaner and more readable with one folder per component, one folder for data providers and one other for models (i.e business entities). To know more about this pattern, read this article or just study the react-todo-basic sample.
An other pretty convenient library if you work with React is the immuatbility helper: This library allows to mutate a copy of data without changing the original source, like this:
123456 | private _addFilter(filterToAdd: IRefinementFilter): void { // Add the filter to the selected filters collection let newFilters = update(this.state.selectedFilters, {$push: [filterToAdd]}); this._applyFilters(newFilters);} |
Search results response mapping
Because search results are by definition heterogeneous, we can’t simply use a common interface to aggregate all properties so that’s why I simply use a generic interface to build result objects dynamically:
1234567891011121314 | export interface ISearchResult { [key: string]: string; IconSrc?: string;} … // Build item result dynamically where item.Key is the managed property name // and item.Value its value as stringlet result: ISearchResult = {}; elt.Cells.map((item) => { result[item.Key] = item.Value;}); |
Notice that the sp-pnp-js library already provides TypeScript typings for SharePoint search API REST response (you don’t need to do it yourself):
1 | import pnp, { .. SearchQuery, SearchQueryBuilder, SearchResults … } from “sp-pnp-js”; |
The icon for the result type (Word, PDF, etc.) is fetched dynamically via the native mapToIcon() REST method. You can set the icon size to retrieve as parameter (16×16 pixels = 0, 32×32 pixels = 1 (default = 0)):
12 | const iconFileName = await web.mapToIcon(encodedFileName,1);const iconUrl = webAbsoluteUrl + “/_layouts/15/images/” + iconFileName; |
Refinements handling
Refiners (i.e search filters) are retrieved from the search results of the first page via the searchQueryRefiners property (set as query parameter). It means that when an user filters the results or switches the current page, refiners are not updated according the new search results, as the default behavior of SharePoint. It means results can be empty regarding the current filters combination. We use this strategy to always have a consequent filter behavior in the UI and avoid frustration for users.
When an user applies filters, a custom refinement query is built via the searchQuery.RefinementFilters property.As a reminder, refinement filters uses the SharePoint FQL syntax. The code to build this query is availaable in the _buildRefinementQueryString() method. We do a AND condition between filter properties and a OR condition between values of a single filter property:
123456789101112131415161718192021222324252627282930313233343536373839404142 | private _buildRefinementQueryString(selectedFilters: IRefinementFilter[]): string { let refinementQueryConditions: string[] = []; let refinementQueryString: string = null; const refinementFilters = mapValues(groupBy(selectedFilters, ‘FilterName’), (values) => { const refinementFilter = values.map((filter) => { return filter.Value.RefinementToken; }); return refinementFilter.length > 1 ? “or(” + refinementFilter + “)” : refinementFilter.toString(); }); mapKeys(refinementFilters, (value, key) => { refinementQueryConditions.push(key + “:” + value); }); const conditionsCount = refinementQueryConditions.length; switch (true) { // No filters case (conditionsCount === 0): { refinementQueryString = null; break; } // Just one filter case (conditionsCount === 1): { refinementQueryString = refinementQueryConditions[0].toString(); break; } // Multiple filters case (conditionsCount > 1): { refinementQueryString = “and(” + refinementQueryConditions.toString() + “)”; break; } } return refinementQueryString;} |
User Interface
I’ve used the following Office UI Fabric:
- Panel to display refinement panel ;). Can be positioned on the left or right.
- GroupedList: An underestimated component that can be easily used to build collapsible sections.
- Checkbox to activate/deactivate refiners individually.
- DocumentCard with preview to display results as tiles with a responsive design
- Overlay, MessageBar and Spinner to handle errors/messages and waiting sequences.
- Button to build selected refiners.
Pagination
For the pagination, we rely directly on the SharePoint search API, results per pages are retrieved via the dedicated PnP library getPage() method. All we need to do, is to pass the right page number and the number of results we want:
1 | const r2 = await r.getPage(page, this._resultsCount); |
By this way, we make the pagination dynamic instead of doing it « post-render » by taking advantage of the API.
Note: there is a bug prior to the 2.0.8 sp-pnp-js version regarding the page calculation. More info here: https://github.com/SharePoint/PnP-JS-Core/issues/535.
Then, to build the custom pagination control, I’ve used this very handy React component (react-js-pagination):
PnP controls
This sample also showcases the use of the PnP SPFx Controls via the Placeholder. The integration is pretty easy and done at the top level class of the Web Part like this:
1234567891011121314151617181920 | import { Placeholder, IPlaceholderProps } from “@pnp/spfx-controls-react/lib/Placeholder”; … 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) }); … private _setupWebPart() { this.context.propertyPane.open();} |
This control is very useful for the initial Web Part loading scenario, when the Web Part haven’t been configured yet:
Hope this sample will give you a starting point to build awesome search experiences with SPFx. See you soon for others cool SPFx components!