[react-search-refiners] Miscellaneous updates (#695)

* * Migrated to SPFx 1.7.0
* Fixed sort feature
* Added a sample TypeScript function to demonstrate NLP processing for the search query
* Miscelleanous improvements

* * Fixed wrong ids and dependencies

* * Updated README

* * Replaced JSOM taxonomy methods by the @pnp/sp-taxonomy counterparts + refactored refiners translation logic
* Updated the filter panel to close on click out
* Updgraded to @pnp 1.2.6
* Added a event listeners for hash change when the search box in bound to the 'URL fragment' SPFx builtin data source property so you can now build predefined filters with '#'.
* Fix suggestions panel position to be absolute

* * Quick fix on the search box

* * Added a default query option (related to https://github.com/SharePoint/sp-dev-fx-webparts/issues/556)

* * Added the ability to search by clicking on the search box icon
This commit is contained in:
Franck Cornu 2018-11-29 03:40:19 -05:00 committed by Mikael Svenson
parent 0265e4c7e6
commit 398d3b0363
19 changed files with 2645 additions and 2096 deletions

View File

@ -44,7 +44,5 @@ build.configureWebpack.mergeConfig({
return generatedConfiguration; return generatedConfiguration;
} }
}); });
build.webpack.buildConfig
build.addSuppression(new RegExp("\[sass\]",'g'));
build.initialize(gulp); build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -19,11 +19,13 @@
"@microsoft/sp-lodash-subset": "1.7.0", "@microsoft/sp-lodash-subset": "1.7.0",
"@microsoft/sp-office-ui-fabric-core": "1.7.0", "@microsoft/sp-office-ui-fabric-core": "1.7.0",
"@microsoft/sp-webpart-base": "1.7.0", "@microsoft/sp-webpart-base": "1.7.0",
"@pnp/common": "1.2.5", "@pnp/common": "1.2.6",
"@pnp/logging": "1.2.5", "@pnp/logging": "1.2.6",
"@pnp/odata": "1.2.5", "@pnp/odata": "1.2.6",
"@pnp/polyfill-ie11": "1.0.0", "@pnp/polyfill-ie11": "1.0.0",
"@pnp/sp": "1.2.5", "@pnp/sp": "1.2.6",
"@pnp/sp-taxonomy": "1.2.6",
"@pnp/sp-clientsvc": "1.2.6",
"@pnp/spfx-controls-react": "1.10.0", "@pnp/spfx-controls-react": "1.10.0",
"@pnp/spfx-property-controls": "1.12.0", "@pnp/spfx-property-controls": "1.12.0",
"@types/es6-promise": "0.0.33", "@types/es6-promise": "0.0.33",
@ -43,7 +45,7 @@
"on-el-resize": "0.0.4", "on-el-resize": "0.0.4",
"react": "16.3.2", "react": "16.3.2",
"react-ace": "6.1.4", "react-ace": "6.1.4",
"react-custom-scrollbars": "4.1.2", "react-custom-scrollbars": "4.2.1",
"react-dom": "16.3.2", "react-dom": "16.3.2",
"react-js-pagination": "3.0.0", "react-js-pagination": "3.0.0",
"video.js": "^7.3.0" "video.js": "^7.3.0"

View File

@ -161,8 +161,9 @@ class SearchService implements ISearchService {
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: any = refinementResultsRows ? refinementResultsRows.Refiners : [];
if (refinementRows.length > 0) { if (refinementRows.length > 0) {
const component = await import( const component = await import(
/* webpackChunkName: 'search-handlebars-helpers' */ /* webpackChunkName: 'search-handlebars-helpers' */
'handlebars-helpers' 'handlebars-helpers'

View File

@ -1,18 +1,13 @@
interface ITaxonomyService { import { ITerm } from "@pnp/sp-taxonomy";
/** interface ITaxonomyService {
* Ensure all script dependencies are loaded before using the taxonomy SharePoint CSOM functions
* https://dev.office.com/sharepoint/docs/spfx/web-parts/guidance/connect-to-sharepoint-using-jsom
* @return {Promise<void>} A promise allowing you to execute your code logic.
*/
initialize();
/** /**
* Gets multiple terms by their ids using the current taxonomy context * Gets multiple terms by their ids using the current taxonomy context
* @param termIds An array of term ids to search for * @param termIds An array of term ids to search for
* @return {Promise<SP.Taxonomy.TermCollection>} A promise containing the terms. * @return {Promise<ITerm[]>} A promise containing the terms.
*/ */
getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection>; getTermsById(termIds: string[]): Promise<ITerm[]>;
} }
export default ITaxonomyService; export default ITaxonomyService;

View File

@ -1,5 +1,6 @@
import ITaxonomyService from './ITaxonomyService'; import ITaxonomyService from './ITaxonomyService';
import { ITerm } from '@pnp/sp-taxonomy';
class MockTaxonomyService implements ITaxonomyService { class MockTaxonomyService implements ITaxonomyService {
@ -11,7 +12,7 @@ class MockTaxonomyService implements ITaxonomyService {
return p1; return p1;
} }
public getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection> { public getTermsById(termIds: string[]): Promise<ITerm[]> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
} }

View File

@ -1,129 +1,38 @@
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { Logger, LogLevel } from '@pnp/logging';
import { SPComponentLoader } from '@microsoft/sp-loader';
import ITaxonomyService from './ITaxonomyService'; import ITaxonomyService from './ITaxonomyService';
import { Text } from '@microsoft/sp-core-library'; import { ITermStore, ITerms, ITermData, Session, ITerm } from "@pnp/sp-taxonomy";
class TaxonomyService implements ITaxonomyService { class TaxonomyService implements ITaxonomyService {
private _workingLanguageLcid: number; private _siteUrl: string;
private _context: IWebPartContext;
public constructor(webPartContext: IWebPartContext, workingLanguage?: number){ public constructor(siteUrl: string){
this._context = webPartContext; this._siteUrl = siteUrl;
this._workingLanguageLcid = workingLanguage ? workingLanguage : null;
}
/**
* Ensure all script dependencies are loaded before using the taxonomy SharePoint CSOM functions
* https://dev.office.com/sharepoint/docs/spfx/web-parts/guidance/connect-to-sharepoint-using-jsom
* @return {Promise<void>} A promise allowing you to execute your code logic.
*/
public initialize(): Promise<void> {
const loadScriptPromise = new Promise<void>((resolve) => {
const siteCollectionUrl = this._context.pageContext.site.absoluteUrl;
SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/init.js', {
globalExportsName: '$_global_init',
})
.catch((error) => {
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "init.js", error));
})
.then((): Promise<{}> => {
return SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/MicrosoftAjax.js', {
globalExportsName: 'Sys'
});
})
.catch((error) => {
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "MicrosoftAjax.js", error));
})
.then((): Promise<{}> => {
// The SP.Runtime.js file is needed in the hosted workbench environment
// However, in a production environment, there will be an error message in the console saying the file is loaded twice
// This is not a real issue for our purpose so we can keep these lines.
return SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/SP.Runtime.js', {
globalExportsName: 'SP'
});
})
.catch((error) => {
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "SP.Runtime.js", error));
})
.then((): Promise<{}> => {
return SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/SP.js', {
globalExportsName: 'SP'
});
})
.catch((error) => {
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "SP.js", error));
})
.then((): Promise<{}> => {
return SPComponentLoader.loadScript(siteCollectionUrl + '/_layouts/15/SP.taxonomy.js', {
globalExportsName: 'SP'
});
})
.catch((error) => {
Logger.write(Text.format("Error when loading '{0}' script. Details: {1}.", "SP.taxonomy.js", error));
})
.then(() => {
// Hack the default method to pass the correct parameters to the server (bug in SP.taxonomy.js)
// https://www.stephensaw.me/sharepoint-sp-taxonomy-js-term-getterms-not-working/
const getTerms: any = function (g, h, e, d, c, f) {
var a = this.get_context(), b;
b = new SP.Taxonomy.TermCollection(a, new SP.ObjectPathMethod(a, this.get_path(), "GetTerms", [g, h, e, d, c, f]));
return b;
};
SP.Taxonomy.Term.prototype.getTerms = getTerms;
resolve();
});
});
return loadScriptPromise;
} }
/** /**
* Gets multiple terms by their ids using the current taxonomy context * Gets multiple terms by their ids using the current taxonomy context
* @param termIds An array of term ids to search for * @param termIds An array of term ids to search for
*/ */
public getTermsById(termIds: string[]): Promise<SP.Taxonomy.TermCollection> { public async getTermsById(termIds: string[]): Promise<(ITerm & ITermData)[]> {
if (termIds.length > 0) { if (termIds.length > 0) {
const spContext = SP.ClientContext.get_current(); const taxonomySession = new Session(this._siteUrl);
const taxSession: SP.Taxonomy.TaxonomySession = SP.Taxonomy.TaxonomySession.getTaxonomySession(spContext); taxonomySession.setup({
const termStore = taxSession.getDefaultSiteCollectionTermStore(); sp: {
headers: {
if (this._workingLanguageLcid) { Accept: "application/json;odata=nometadata",
termStore.set_workingLanguage(this._workingLanguageLcid); },
} },
// The namespace SP is only available here (because of the init() method)
const terms: SP.Taxonomy.TermCollection = termStore.getTermsById(termIds.map(t => new SP.Guid(t)));
// Additional properties can be loaded here
spContext.load(terms, "Include(Id, Name)");
const p = new Promise<SP.Taxonomy.TermCollection>((resolve, reject) => {
spContext.executeQueryAsync(() => {
resolve(terms);
}, (sender, args) => {
const errorMessage = "[TaxonomyProvider.getTermById()]: Error: " + args.get_message();
Logger.write(errorMessage, LogLevel.Error);
reject(errorMessage);
});
}); });
return p; // Get the default termstore
const store: ITermStore = await taxonomySession.getDefaultSiteCollectionTermStore();
const terms: ITerms = await store.getTermsById(...termIds);
return await terms.select('Id','Labels').get();
} else {
return [];
} }
} }
} }

View File

@ -1,5 +1,7 @@
.searchBox { .searchBox {
position: relative;
.errorMessage { .errorMessage {
margin-bottom: 10px; margin-bottom: 10px;
} }
@ -12,10 +14,31 @@
padding: 10px; padding: 10px;
} }
.searchFieldGroup {
display: flex;
position: relative;
.searchTextField {
width: 100%;
}
.searchBtn {
position: absolute;
right: 0;
}
}
.suggestionPanel { .suggestionPanel {
position: absolute;
width: 100%;
box-sizing: border-box;
z-index: 1;
background-color: #ffffff;
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc; border-left: 1px solid #ccc;
border-right: 1px solid #ccc; border-right: 1px solid #ccc;
box-sizing: border-box;
} }
.selected { .selected {

View File

@ -46,6 +46,8 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
rawInputValue: '', rawInputValue: '',
enhancedQuery: '' enhancedQuery: ''
}; };
this._bindHashChange = this._bindHashChange.bind(this);
} }
public render(): void { public render(): void {
@ -53,6 +55,9 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
let inputValue = this.properties.defaultQueryKeywords.tryGetValue(); let inputValue = this.properties.defaultQueryKeywords.tryGetValue();
if (inputValue && typeof(inputValue) === 'string') { if (inputValue && typeof(inputValue) === 'string') {
// Notify subsscriber a new value if available
this.context.dynamicDataSourceManager.notifyPropertyChanged('searchQuery');
this._searchQuery.rawInputValue = inputValue; this._searchQuery.rawInputValue = inputValue;
} }
@ -119,6 +124,8 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
this.initSearchService(); this.initSearchService();
this.initNlpService(); this.initNlpService();
this._bindHashChange();
return Promise.resolve(); return Promise.resolve();
} }
@ -157,6 +164,16 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
protected onPropertyPaneFieldChanged(propertyPath: string) { protected onPropertyPaneFieldChanged(propertyPath: string) {
this.initSearchService(); this.initSearchService();
this.initNlpService(); this.initNlpService();
if (!this.properties.useDynamicDataSource) {
this.properties.defaultQueryKeywords.setValue("");
} else {
this._bindHashChange();
}
if (propertyPath === 'enableNlpService') {
this.properties.enableDebugMode = !this.properties.enableDebugMode ? false : true;
}
} }
/** /**
@ -352,4 +369,19 @@ export default class SearchBoxWebPart extends BaseClientSideWebPart<ISearchBoxWe
return searchQueryOptimizationFields; return searchQueryOptimizationFields;
} }
/**
* Subscribes to URL hash change if the dynamic property is set to the default 'URL Fragment' property
*/
private _bindHashChange() {
if (this.properties.defaultQueryKeywords.tryGetSource()) {
if (this.properties.defaultQueryKeywords.reference.localeCompare('PageContext:UrlData:fragment') === 0) {
// Manually subscribe to hash change since the default property doesn't
window.addEventListener('hashchange', this.render);
} else {
window.removeEventListener('hashchange', this.render);
}
}
}
} }

View File

@ -13,6 +13,7 @@ import * as update from 'immutability-helper';
import styles from '../SearchBoxWebPart.module.scss'; import styles from '../SearchBoxWebPart.module.scss';
import ISearchQuery from '../../../models/ISearchQuery'; import ISearchQuery from '../../../models/ISearchQuery';
import NlpDebugPanel from './NlpDebugPanel/NlpDebugPanel'; import NlpDebugPanel from './NlpDebugPanel/NlpDebugPanel';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
const SUGGESTION_CHAR_COUNT_TRIGGER = 3; const SUGGESTION_CHAR_COUNT_TRIGGER = 3;
@ -48,45 +49,49 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
selectedItem, selectedItem,
highlightedIndex, highlightedIndex,
openMenu, openMenu,
clearItems clearItems,
}) => ( }) => (
<div> <div>
<TextField {...getInputProps({ <div className={ styles.searchFieldGroup }>
placeholder: strings.SearchInputPlaceholder, <TextField {...getInputProps({
onKeyDown: event => { placeholder: strings.SearchInputPlaceholder,
onKeyDown: event => {
// Submit search on "Enter" // Submit search on "Enter"
if (event.keyCode === 13 && (!isOpen || (isOpen && highlightedIndex === null))) { if (event.keyCode === 13 && (!isOpen || (isOpen && highlightedIndex === null))) {
this._onSearch(this.state.searchInputValue); this._onSearch(this.state.searchInputValue);
}
} }
} })}
})} className={ styles.searchTextField }
value={ this.state.searchInputValue } value={ this.state.searchInputValue }
autoComplete= "off" autoComplete= "off"
onChanged={ (value) => { onChanged={ (value) => {
this.setState({ this.setState({
searchInputValue: value, searchInputValue: value,
}); });
if (this.state.selectedQuerySuggestions.length === 0) { if (this.state.selectedQuerySuggestions.length === 0) {
clearItems(); clearItems();
this._onChange(value); this._onChange(value);
openMenu(); openMenu();
} else { } else {
if (!value) { if (!value) {
// Reset the selected suggestions if input is empty // Reset the selected suggestions if input is empty
this.setState({ this.setState({
selectedQuerySuggestions: [], selectedQuerySuggestions: [],
}); });
}
} }
} }}/>
}} <IconButton iconProps={{
iconProps={{ iconName: 'Search',
iconName: 'Search', iconType: IconType.default,
iconType: IconType.default }} onClick= {() => { this._onSearch(this.state.searchInputValue);} } className={ styles.searchBtn }>
}}/> </IconButton>
</div>
{isOpen ? {isOpen ?
this.renderSuggestions(getItemProps, selectedItem, highlightedIndex) this.renderSuggestions(getItemProps, selectedItem, highlightedIndex)
: null} : null}
@ -96,25 +101,30 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
} }
private renderBasicSearchBox(): JSX.Element { private renderBasicSearchBox(): JSX.Element {
return <TextField return <div className={ styles.searchFieldGroup }>
placeholder={ strings.SearchInputPlaceholder } <TextField
value={ this.state.searchInputValue } className={ styles.searchTextField }
onChanged={ (value) => { placeholder={ strings.SearchInputPlaceholder }
this.setState({ value={ this.state.searchInputValue }
searchInputValue: value, onChanged={ (value) => {
}); this.setState({
}} searchInputValue: value,
onKeyDown={ (event) => { });
}}
onKeyDown={ (event) => {
// Submit search on "Enter" // Submit search on "Enter"
if (event.keyCode === 13) { if (event.keyCode === 13) {
this._onSearch(this.state.searchInputValue); this._onSearch(this.state.searchInputValue);
} }
}} }}
iconProps={{ />
iconName: 'Search', <IconButton iconProps={{
iconType: IconType.default iconName: 'Search',
}}/>; iconType: IconType.default,
}} onClick= {() => { this._onSearch(this.state.searchInputValue);} } className={ styles.searchBtn }>
</IconButton>
</div>;
} }
/** /**
@ -251,47 +261,52 @@ export default class SearchBoxContainer extends React.Component<ISearchBoxContai
*/ */
public async _onSearch(queryText: string) { public async _onSearch(queryText: string) {
let query: ISearchQuery = { // Don't send empty value
rawInputValue: queryText, if (queryText) {
enhancedQuery: ''
};
this.setState({ let query: ISearchQuery = {
searchInputValue: queryText, rawInputValue: queryText,
}); enhancedQuery: ''
};
if (this.props.enableNlpService && this.props.NlpService && queryText) { this.setState({
searchInputValue: queryText,
});
try { if (this.props.enableNlpService && this.props.NlpService && queryText) {
let enhancedQuery = await this.props.NlpService.enhanceSearchQuery(queryText, this.props.isStaging); try {
query.enhancedQuery = enhancedQuery.enhancedQuery;
enhancedQuery.entities.map((entity) => { let enhancedQuery = await this.props.NlpService.enhanceSearchQuery(queryText, this.props.isStaging);
}); query.enhancedQuery = enhancedQuery.enhancedQuery;
this.setState({ enhancedQuery.entities.map((entity) => {
enhancedQuery: enhancedQuery, });
});
} catch (error) { this.setState({
enhancedQuery: enhancedQuery,
// In case of failure, use the non-optimized query instead });
query.enhancedQuery = queryText;
} catch (error) {
// In case of failure, use the non-optimized query instead
query.enhancedQuery = queryText;
}
} }
}
if (this.props.searchInNewPage) { 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)); // Send the query to the a new via the hash
const url = `${this.props.pageUrl}#${encodeURIComponent(queryText)}`;
const behavior = this.props.openBehavior === PageOpenBehavior.NewTab ? '_blank' : '_self'; const behavior = this.props.openBehavior === PageOpenBehavior.NewTab ? '_blank' : '_self';
window.open(url, behavior); window.open(url, behavior);
} else { } else {
// Notify the dynamic data controller // Notify the dynamic data controller
this.props.onSearch(query); this.props.onSearch(query);
}
} }
} }

View File

@ -2,6 +2,7 @@ define([], function() {
return { return {
"SearchInputPlaceholder": "Entrez vos termes de recherche...", "SearchInputPlaceholder": "Entrez vos termes de recherche...",
"SearchBoxNewPage": "Options de la boîte de recherche", "SearchBoxNewPage": "Options de la boîte de recherche",
"SearchBoxEnableQuerySuggestions": "Activer les suggestions de recherche",
"SearchBoxSearchInNewPageLabel": "Envoyer la requête sur une nouvelle page", "SearchBoxSearchInNewPageLabel": "Envoyer la requête sur une nouvelle page",
"SearchBoxPageUrlLabel": "URL de la page", "SearchBoxPageUrlLabel": "URL de la page",
"SearchBoxUrlErrorMessage": "Veuillez spécifier une URL valide", "SearchBoxUrlErrorMessage": "Veuillez spécifier une URL valide",

View File

@ -3,6 +3,8 @@ import { DynamicProperty } from '@microsoft/sp-component-base';
export interface ISearchResultsWebPartProps { export interface ISearchResultsWebPartProps {
queryKeywords: DynamicProperty<string>; queryKeywords: DynamicProperty<string>;
defaultSearchQuery: string;
useDefaultSearchQuery: boolean;
queryTemplate: string; queryTemplate: string;
resultSourceId: string; resultSourceId: string;
sortList: string; sortList: string;

View File

@ -21,11 +21,11 @@
}, },
"title": { "title": {
"default": "Search Results with Refiners", "default": "Search Results with Refiners",
"fr-fr": "Résultats de recherche" "fr-fr": "Résultats de recherche"
}, },
"description": { "description": {
"default": "Displays search results with customizable dynamic refiners", "default": "Displays search results with customizable dynamic refiners",
"fr-fr": "Affiche des résulats de recherche avec filtres personnalisables" "fr-fr": "Affiche des résulats de recherche avec filtres personnalisables"
}, },
"officeFabricIconFontName": "SearchAndApps", "officeFabricIconFontName": "SearchAndApps",
"properties": { "properties": {
@ -37,7 +37,8 @@
"maxResultsCount": 10, "maxResultsCount": 10,
"showBlank": true, "showBlank": true,
"showResultsCount": true, "showResultsCount": true,
"webPartTitle": "" "webPartTitle": "",
"useDefaultSearchQuery": false
} }
} }
] ]

View File

@ -1,4 +1,4 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDom from 'react-dom'; import * as ReactDom from 'react-dom';
import { Version, Text, Environment, EnvironmentType, DisplayMode, Log } from '@microsoft/sp-core-library'; import { Version, Text, Environment, EnvironmentType, DisplayMode, Log } from '@microsoft/sp-core-library';
import { import {
@ -14,7 +14,9 @@ import {
PropertyPaneToggle, PropertyPaneToggle,
PropertyPaneSlider, PropertyPaneSlider,
IPropertyPaneChoiceGroupOption, IPropertyPaneChoiceGroupOption,
PropertyPaneChoiceGroup PropertyPaneChoiceGroup,
PropertyPaneCheckbox,
} from '@microsoft/sp-webpart-base'; } from '@microsoft/sp-webpart-base';
import * as strings from 'SearchResultsWebPartStrings'; import * as strings from 'SearchResultsWebPartStrings';
import SearchResultsContainer from './components/SearchResultsContainer/SearchResultsContainer'; import SearchResultsContainer from './components/SearchResultsContainer/SearchResultsContainer';
@ -27,7 +29,6 @@ import TemplateService from '../../services/TemplateService/TemplateService';
import { update, isEmpty } from '@microsoft/sp-lodash-subset'; import { update, isEmpty } from '@microsoft/sp-lodash-subset';
import MockSearchService from '../../services/SearchService/MockSearchService'; import MockSearchService from '../../services/SearchService/MockSearchService';
import MockTemplateService from '../../services/TemplateService/MockTemplateService'; import MockTemplateService from '../../services/TemplateService/MockTemplateService';
import LocalizationHelper from '../../helpers/LocalizationHelper';
import SearchService from '../../services/SearchService/SearchService'; import SearchService from '../../services/SearchService/SearchService';
import TaxonomyService from '../../services/TaxonomyService/TaxonomyService'; import TaxonomyService from '../../services/TaxonomyService/TaxonomyService';
import MockTaxonomyService from '../../services/TaxonomyService/MockTaxonomyService'; import MockTaxonomyService from '../../services/TaxonomyService/MockTaxonomyService';
@ -35,7 +36,6 @@ import ISearchResultsContainerProps from './components/SearchResultsContainer/IS
import { Placeholder, IPlaceholderProps } from '@pnp/spfx-controls-react/lib/Placeholder'; import { Placeholder, IPlaceholderProps } from '@pnp/spfx-controls-react/lib/Placeholder';
import { SPHttpClientResponse, SPHttpClient } from '@microsoft/sp-http'; import { SPHttpClientResponse, SPHttpClient } from '@microsoft/sp-http';
declare var System: any;
const LOG_SOURCE: string = '[SearchResultsWebPart_{0}]'; const LOG_SOURCE: string = '[SearchResultsWebPart_{0}]';
export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchResultsWebPartProps> { export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchResultsWebPartProps> {
@ -54,6 +54,7 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
constructor() { constructor() {
super(); super();
this._parseFieldListString = this._parseFieldListString.bind(this); this._parseFieldListString = this._parseFieldListString.bind(this);
} }
public async render(): Promise<void> { public async render(): Promise<void> {
@ -83,6 +84,8 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
protected renderCompleted(): void { protected renderCompleted(): void {
super.renderCompleted(); super.renderCompleted();
let queryKeywords;
let renderElement = null; let renderElement = null;
if (typeof this.properties.useHandlebarsHelpers === 'undefined') { if (typeof this.properties.useHandlebarsHelpers === 'undefined') {
this.properties.useHandlebarsHelpers = true; this.properties.useHandlebarsHelpers = true;
@ -96,6 +99,12 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
this.context.propertyPane.refresh(); this.context.propertyPane.refresh();
} }
if (!dataSourceValue) {
queryKeywords = this.properties.defaultSearchQuery;
} else {
queryKeywords = dataSourceValue;
}
const isValueConnected = !!this.properties.queryKeywords.tryGetSource(); const isValueConnected = !!this.properties.queryKeywords.tryGetSource();
const searchContainer: React.ReactElement<ISearchResultsContainerProps> = React.createElement( const searchContainer: React.ReactElement<ISearchResultsContainerProps> = React.createElement(
@ -103,7 +112,7 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
{ {
searchService: this._searchService, searchService: this._searchService,
taxonomyService: this._taxonomyService, taxonomyService: this._taxonomyService,
queryKeywords: this.properties.queryKeywords.tryGetValue(), queryKeywords: queryKeywords,
maxResultsCount: this.properties.maxResultsCount, maxResultsCount: this.properties.maxResultsCount,
resultSourceId: this.properties.resultSourceId, resultSourceId: this.properties.resultSourceId,
sortList: this.properties.sortList, sortList: this.properties.sortList,
@ -133,7 +142,9 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
} }
); );
if (isValueConnected || (!isValueConnected && !isEmpty(this.properties.queryKeywords.tryGetValue()))) { if (isValueConnected && !this.properties.useDefaultSearchQuery ||
isValueConnected && this.properties.useDefaultSearchQuery && this.properties.defaultSearchQuery ||
!isValueConnected && !isEmpty(queryKeywords)) {
renderElement = searchContainer; renderElement = searchContainer;
} else { } else {
if (this.displayMode === DisplayMode.Edit) { if (this.displayMode === DisplayMode.Edit) {
@ -155,10 +166,8 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
} else { } else {
const lcid = LocalizationHelper.getLocaleId(this.context.pageContext.cultureInfo.currentUICultureName);
this._searchService = new SearchService(this.context); this._searchService = new SearchService(this.context);
this._taxonomyService = new TaxonomyService(this.context, lcid); this._taxonomyService = new TaxonomyService(this.context.pageContext.site.absoluteUrl);
this._templateService = new TemplateService(this.context.spHttpClient, this.context.pageContext.cultureInfo.currentUICultureName); this._templateService = new TemplateService(this.context.spHttpClient, this.context.pageContext.cultureInfo.currentUICultureName);
} }
@ -167,6 +176,7 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
// Configure search query settings // Configure search query settings
this._useResultSource = false; this._useResultSource = false;
// Set the default search results layout // Set the default search results layout
this.properties.selectedLayout = this.properties.selectedLayout ? this.properties.selectedLayout : ResultsLayoutOption.List; this.properties.selectedLayout = this.properties.selectedLayout ? this.properties.selectedLayout : ResultsLayoutOption.List;
@ -234,6 +244,10 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
protected async onPropertyPaneFieldChanged(propertyPath: string) { protected async onPropertyPaneFieldChanged(propertyPath: string) {
if (!this.properties.useDefaultSearchQuery) {
this.properties.defaultSearchQuery = '';
}
if (propertyPath === 'selectedLayout') { if (propertyPath === 'selectedLayout') {
// Refresh setting the right template for the property pane // Refresh setting the right template for the property pane
await this._getTemplateContent(); await this._getTemplateContent();
@ -543,6 +557,30 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
*/ */
private _getSearchQueryFields(): IPropertyPaneConditionalGroup { private _getSearchQueryFields(): IPropertyPaneConditionalGroup {
let defaultSearchQueryFields: IPropertyPaneField<any>[] = [];
if (!!this.properties.queryKeywords.tryGetSource()) {
defaultSearchQueryFields.push(
PropertyPaneCheckbox('useDefaultSearchQuery', {
text: strings.UseDefaultSearchQueryKeywordsFieldLabel
})
);
}
if (this.properties.useDefaultSearchQuery) {
defaultSearchQueryFields.push(
PropertyPaneTextField('defaultSearchQuery', {
label: strings.DefaultSearchQueryKeywordsFieldLabel,
description: strings.DefaultSearchQueryKeywordsFieldDescription,
multiline: true,
resizable: true,
placeholder: strings.SearchQueryPlaceHolderText,
onGetErrorMessage: this._validateEmptyField.bind(this),
deferredValidationTime: 500
})
);
}
return { return {
primaryGroup: { primaryGroup: {
groupFields: [ groupFields: [
@ -561,20 +599,29 @@ export default class SearchResultsWebPart extends BaseClientSideWebPart<ISearchR
groupFields: [ groupFields: [
PropertyPaneDynamicFieldSet({ PropertyPaneDynamicFieldSet({
label: strings.SearchQueryKeywordsFieldLabel, label: strings.SearchQueryKeywordsFieldLabel,
fields: [ fields: [
PropertyPaneDynamicField('queryKeywords', { PropertyPaneDynamicField('queryKeywords', {
label: strings.SearchQueryKeywordsFieldLabel label: strings.SearchQueryKeywordsFieldLabel
}) })
], ],
sharedConfiguration: { sharedConfiguration: {
depth: DynamicDataSharedDepth.Source, depth: DynamicDataSharedDepth.Source,
} },
}) }),
] ].concat(defaultSearchQueryFields)
}, },
// Show the secondary group only if the web part has been // Show the secondary group only if the web part has been
// connected to a dynamic data source // connected to a dynamic data source
showSecondaryGroup: !!this.properties.queryKeywords.tryGetSource(), showSecondaryGroup: !!this.properties.queryKeywords.tryGetSource(),
onShowPrimaryGroup: () => {
// Reset dynamic data fields related values to be consistent
this.properties.useDefaultSearchQuery = false;
this.properties.defaultSearchQuery = '';
this.properties.queryKeywords.setValue('');
this.render();
}
} as IPropertyPaneConditionalGroup; } as IPropertyPaneConditionalGroup;
} }

View File

@ -133,12 +133,9 @@ export default class FilterPanel extends React.Component<IFilterPanelProps, IFil
isOpen={this.state.showPanel} isOpen={this.state.showPanel}
type={PanelType.custom} type={PanelType.custom}
customWidth="450px" customWidth="450px"
isBlocking={false}
isLightDismiss={true} isLightDismiss={true}
onDismiss={this._onClosePanel} onDismiss={this._onClosePanel}
headerText={strings.FilterPanelTitle} headerText={strings.FilterPanelTitle}
closeButtonAriaLabel='Close'
hasCloseButton={true}
onRenderBody={() => { onRenderBody={() => {
if (this.props.availableFilters.length > 0) { if (this.props.availableFilters.length > 0) {
return ( return (

View File

@ -15,6 +15,9 @@ import SearchResultsTemplate from '../Layouts/SearchResultsTemplate';
import styles from '../SearchResultsWebPart.module.scss'; import styles from '../SearchResultsWebPart.module.scss';
import { SortPanel } from '../SortPanel'; import { SortPanel } from '../SortPanel';
import { SortDirection } from "@pnp/sp"; import { SortDirection } from "@pnp/sp";
import { ITermData, ITerm } from '@pnp/sp-taxonomy';
import LocalizationHelper from '../../../../helpers/LocalizationHelper';
import { Text } from '@microsoft/sp-core-library';
declare var System: any; declare var System: any;
let FilterPanel = null; let FilterPanel = null;
@ -282,7 +285,9 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
} }
} else { } else {
this.setState({ this.setState({
areResultsLoading: false areResultsLoading: false,
lastQuery: '',
results: { RefinementResults: [], RelevantResults: [] },
}); });
} }
} else { } else {
@ -404,8 +409,12 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
*/ */
private async _getLocalizedFilters(rawFilters: IRefinementResult[]): Promise<IRefinementResult[]> { private async _getLocalizedFilters(rawFilters: IRefinementResult[]): Promise<IRefinementResult[]> {
// Get the current lcid according to current page language
const lcid = LocalizationHelper.getLocaleId(this.props.context.pageContext.cultureInfo.currentUICultureName);
let termsToLocalize: { uniqueIdentifier: string, termId: string, localizedTermLabel: string }[] = []; let termsToLocalize: { uniqueIdentifier: string, termId: string, localizedTermLabel: string }[] = [];
let updatedFilters = []; let updatedFilters = [];
let localizedTerms = [];
rawFilters.map((filterResult) => { rawFilters.map((filterResult) => {
@ -432,45 +441,49 @@ export default class SearchResultsContainer extends React.Component<ISearchConta
if (termsToLocalize.length > 0) { if (termsToLocalize.length > 0) {
// Process all terms in a single JSOM call for performance purpose. In general JSOM is pretty slow so we try to limit the number of calls... // Get the terms from taxonomy
await this.props.taxonomyService.initialize(); // If a term doesn't exist anymore, it won't be retrieved by the API so the termValues count could be less than termsToLocalize count
const termValues = await this.props.taxonomyService.getTermsById(termsToLocalize.map((t) => { return t.termId; })); const termValues = await this.props.taxonomyService.getTermsById(termsToLocalize.map((t) => { return t.termId; }));
const termsEnumerator = termValues.getEnumerator(); termsToLocalize.map((termToLocalize) => {
while (termsEnumerator.moveNext()) { // Check if the term has been retrieved from taxonomy (i.e. exists)
const termsFromTaxonomy = termValues.filter((taxonomyTerm: ITerm & ITermData) => {
const termIdFromTaxonomy = taxonomyTerm.Id.substring(taxonomyTerm.Id.indexOf('(') + 1, taxonomyTerm.Id.indexOf(')'));
return termIdFromTaxonomy === termToLocalize.termId;
});
const currentTerm = termsEnumerator.get_current(); if (termsFromTaxonomy.length > 0) {
// Need to do this check in the case where the term indexed by the search doesn't exist anymore in the term store // Should be always unique since we can't have two terms with the same ids
if (!currentTerm.get_serverObjectIsNull()) { const termFromTaxonomy: ITerm & ITermData = termsFromTaxonomy[0];
const termId = currentTerm.get_id(); // It supposes the 'Label' property has been selected in the underlying call
// A term always have a default label so the collection can't be empty
// Check if retrieved term is part of terms to localize const localizedLabel = termFromTaxonomy["Labels"]._Child_Items_.filter((label: any) => {
const terms = termsToLocalize.filter((e) => { return e.termId === termId.toString(); }); return label.Language === lcid;
if (terms.length > 0) { });
termsToLocalize = termsToLocalize.map((term) => {
if (term.termId === terms[0].termId) { localizedTerms.push({
return { uniqueIdentifier: termToLocalize.uniqueIdentifier,
uniqueIdentifier: term.uniqueIdentifier, termId: termToLocalize.termId,
termId: termId.toString(), localizedTermLabel: localizedLabel.length > 0 ? localizedLabel[0].Value : termFromTaxonomy.Name
localizedTermLabel: termsEnumerator.get_current().get_name(), });
}; } else {
} else { localizedTerms.push({
return term; uniqueIdentifier: termToLocalize.uniqueIdentifier,
} termId: termToLocalize.termId,
}); localizedTermLabel: Text.format(strings.TermNotFound, termToLocalize.termId)
} });
} }
} });
// Update original filters with localized values // Update original filters with localized values
rawFilters.map((filter) => { rawFilters.map((filter) => {
let updatedValues = []; let updatedValues = [];
filter.Values.map((value) => { filter.Values.map((value) => {
const existingFilters = termsToLocalize.filter((e) => { return e.uniqueIdentifier === value.RefinementToken; }); const existingFilters = localizedTerms.filter((e) => { return e.uniqueIdentifier === value.RefinementToken; });
if (existingFilters.length > 0) { if (existingFilters.length > 0) {
updatedValues.push({ updatedValues.push({
RefinementCount: value.RefinementCount, RefinementCount: value.RefinementCount,

View File

@ -63,6 +63,10 @@ define([], function() {
"SortPanelSortFieldAria":"Select a field", "SortPanelSortFieldAria":"Select a field",
"SortPanelSortFieldPlaceHolder":"Select a field", "SortPanelSortFieldPlaceHolder":"Select a field",
"SortPanelSortDirectionLabel":"Sort Direction", "SortPanelSortDirectionLabel":"Sort Direction",
} },
"TermNotFound": "(Term with ID '{0}' not found)",
"UseDefaultSearchQueryKeywordsFieldLabel": "Use a default search query",
"DefaultSearchQueryKeywordsFieldLabel": "Default search query",
"DefaultSearchQueryKeywordsFieldDescription": "This query will be used when the data source value is still empty."
} }
}); });

View File

@ -63,6 +63,10 @@ define([], function() {
"SortPanelSortFieldAria":"Sélectionner un champ", "SortPanelSortFieldAria":"Sélectionner un champ",
"SortPanelSortFieldPlaceHolder":"Sélectionner un champ", "SortPanelSortFieldPlaceHolder":"Sélectionner un champ",
"SortPanelSortDirectionLabel":"Direction de tri", "SortPanelSortDirectionLabel":"Direction de tri",
} },
"TermNotFound": "(Terme avec l'ID '{0}' non trouvé)",
"UseDefaultSearchQueryKeywordsFieldLabel": "Utiliser une requête initiale",
"DefaultSearchQueryKeywordsFieldLabel": "Requête de recherche par défaut",
"DefaultSearchQueryKeywordsFieldDescription": "Cette requête sera utilisée par défault dans le cas où la valeur de la source de données est encore vide."
} }
}); });

View File

@ -62,7 +62,11 @@ declare interface ISearchResultsWebPartStrings {
SortPanelSortFieldAria:string; SortPanelSortFieldAria:string;
SortPanelSortFieldPlaceHolder:string; SortPanelSortFieldPlaceHolder:string;
SortPanelSortDirectionLabel:string; SortPanelSortDirectionLabel:string;
} },
TermNotFound: string;
UseDefaultSearchQueryKeywordsFieldLabel: string;
DefaultSearchQueryKeywordsFieldLabel: string;
DefaultSearchQueryKeywordsFieldDescription: string;
} }
declare module 'SearchResultsWebPartStrings' { declare module 'SearchResultsWebPartStrings' {