diff --git a/samples/react-search/README.md b/samples/react-search/README.md index d18739e8c..5a65a8b01 100644 --- a/samples/react-search/README.md +++ b/samples/react-search/README.md @@ -22,6 +22,7 @@ react-search-wp|Elio Struyf (MVP, Ventigrate, [@eliostruyf](https://twitter.com/ Version|Date|Comments -------|----|-------- 0.0.4|September 08, 2016|Initial release +0.0.5|September 27, 2016|Updates for drop 4. Added the abilty to use various search tokens. Plus a logging field to watch search calls. ## 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.** @@ -44,6 +45,8 @@ The search web part is a sample client-side web part built on the SharePoint Fra The web part has built in templating support for internal (created within the project) and external (loaded from a URL) templates. +When adding your query you are able to make use of the following tokens: {Today}, {Today+Number}, {Today-Number}, {CurrentDisplayLanguage}, {User}, {User.Name}, {User.Email}, {Site}, {SiteCollection}. + **Internal templates** Internal templates can be found in the [templates]('./src/webparts/templates') folder. You can start building your own templates by using one of the provided samples. diff --git a/samples/react-search/package.json b/samples/react-search/package.json index 388901a81..3daaf5e50 100644 --- a/samples/react-search/package.json +++ b/samples/react-search/package.json @@ -1,13 +1,13 @@ { "name": "search-wp-spfx", - "version": "0.0.4", + "version": "0.0.5", "private": true, "engines": { "node": ">=0.10.0" }, "dependencies": { - "@microsoft/sp-client-base": "~0.2.0", - "@microsoft/sp-client-preview": "~0.2.0", + "@microsoft/sp-client-base": "~0.3.0", + "@microsoft/sp-client-preview": "~0.4.0", "flux": "^2.1.1", "moment": "^2.14.1", "office-ui-fabric-react": "0.36.0", @@ -15,9 +15,9 @@ "react-dom": "0.14.8" }, "devDependencies": { - "@microsoft/sp-build-web": "~0.5.0", - "@microsoft/sp-module-interfaces": "~0.2.0", - "@microsoft/sp-webpart-workbench": "~0.2.0", + "@microsoft/sp-build-web": "~0.6.0", + "@microsoft/sp-module-interfaces": "~0.3.0", + "@microsoft/sp-webpart-workbench": "~0.4.0", "expose-loader": "^0.7.1", "gulp": "~3.9.1" }, @@ -26,4 +26,4 @@ "clean": "gulp nuke", "test": "gulp test" } -} +} \ No newline at end of file diff --git a/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/IPropertyPaneLoggingFieldProps.ts b/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/IPropertyPaneLoggingFieldProps.ts new file mode 100644 index 000000000..83dc814cd --- /dev/null +++ b/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/IPropertyPaneLoggingFieldProps.ts @@ -0,0 +1,6 @@ +export interface IPropertyPaneLoggingFieldProps { + label?: string; + description?: string; + value: any; + retrieve?: Function; +} \ No newline at end of file diff --git a/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingField.ts b/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingField.ts new file mode 100644 index 000000000..8188050b9 --- /dev/null +++ b/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingField.ts @@ -0,0 +1,81 @@ +import * as React from 'react'; +import * as ReactDom from 'react-dom'; + +import { + IPropertyPaneField, + IPropertyPaneFieldType +} from '@microsoft/sp-client-preview'; + +import { IPropertyPaneLoggingFieldProps } from './IPropertyPaneLoggingFieldProps'; +import PropertyPaneLoggingFieldHost, { IPropertyPaneLoggingFieldHostProps } from './PropertyPaneLoggingFieldHost'; + +export interface IPropertyPaneLoggingFieldPropsInternal extends IPropertyPaneLoggingFieldProps { + onRender(elem: HTMLElement): void; + onDispose(elem: HTMLElement): void; +} + +class PropertyPaneLoggingFieldBuilder implements IPropertyPaneField { + // Properties defined by IPropertyPaneField + public type: IPropertyPaneFieldType = IPropertyPaneFieldType.Custom; + public targetProperty: string = undefined; + public properties: IPropertyPaneLoggingFieldPropsInternal; + + // Logging properties + private label: string; + private description: string; + private value: any; + private retrieve: Function; + + public constructor(props: IPropertyPaneLoggingFieldPropsInternal) { + this.properties = props; + this.properties.onDispose = this.dispose; + this.properties.onRender = this.render; + + this.label = props.label; + this.value = props.value; + this.description = props.description; + this.retrieve = props.retrieve; + } + + /** + * @function + * Render the logging element + */ + private render(elm: HTMLElement): void { + // Construct the JSX properties + const element: React.ReactElement = React.createElement(PropertyPaneLoggingFieldHost, { + label: this.label, + value: this.value, + description: this.description, + retrieve: this.retrieve, + onDispose: this.dispose, + onRender: this.render + }); + + // Calls the REACT content generator + ReactDom.render(element, elm); + } + + /** + * @function + * Disposes the current object + */ + private dispose(elem: HTMLElement): void {} +} + + +export function PropertyPaneLoggingField(properties: IPropertyPaneLoggingFieldProps): IPropertyPaneField { + // Create an internal properties object from the given properties + var newProperties: IPropertyPaneLoggingFieldPropsInternal = { + label: properties.label, + description: properties.description, + value: properties.value, + retrieve: properties.retrieve, + onDispose: null, + onRender: null + }; + + // Calles the PropertyPaneLoggingField builder object + // This object will simulate a PropertyFieldCustom to manage his rendering process + return new PropertyPaneLoggingFieldBuilder(newProperties); +} \ No newline at end of file diff --git a/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingFieldHost.tsx b/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingFieldHost.tsx new file mode 100644 index 000000000..2f7a40b39 --- /dev/null +++ b/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingFieldHost.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { IPropertyPaneLoggingFieldPropsInternal } from './PropertyPaneLoggingField'; +import { Label } from 'office-ui-fabric-react/lib/Label'; + +require('./PropertyPaneLoggingFieldStyling.css'); + +/** + * @interface + * PropertyPaneLoggingFieldHost properties interface + * + */ +export interface IPropertyPaneLoggingFieldHostProps extends IPropertyPaneLoggingFieldPropsInternal {} + +/** + * @interface + * PropertyPaneLoggingFieldHost state interface + * + */ +export interface IPropertyPaneLoggingFieldState { + logging?: any[]; +} + + +/** + * @class + * Renders the controls for PropertyPaneLoggingField component + */ +export default class PropertyPaneLoggingFieldHost extends React.Component { + + /** + * @function + * Contructor + */ + constructor(props: IPropertyPaneLoggingFieldHostProps) { + super(props); + + this.state = { + logging: [] + }; + this.getLogging = this.getLogging.bind(this); + } + + /** + * @function + * componentDidMount + */ + public componentDidMount(): void { + this.setState({ + logging: this.props.value + }); + } + + /** + * @function + * Retrieve new logging value + */ + private getLogging() { + this.setState({ + logging: this.props.retrieve() + }); + } + + /** + * @function + * Renders the key values + */ + private renderValue(val: any, subClass?: string) { + const output = []; + for (const k in val) { + if (typeof val[k] === "object") { + output.push(
{k}: object {this.renderValue(val[k], "subElm")}
); + } else { + output.push(
{k}: {val[k]}
); + } + } + return output; + } + + /** + * @function + * Renders the logging field control + */ + public render(): JSX.Element { + const valToRender = this.renderValue(this.state.logging); + //Renders content + return ( +
+ + { + (() => { + if (typeof this.props.retrieve !== 'undefined') { + return ; + } + })() + } +
{valToRender}
+ { + (() => { + if (typeof this.props.description !== 'undefined') { + return {this.props.description}; + } + })() + } + +
+ ); + } +} \ No newline at end of file diff --git a/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingFieldStyling.scss b/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingFieldStyling.scss new file mode 100644 index 000000000..3139586dd --- /dev/null +++ b/samples/react-search/src/webparts/searchSpfx/PropertyPaneControls/PropertyPaneLoggingFieldStyling.scss @@ -0,0 +1,35 @@ +.loggingField { + margin: 0 0 85px 0; + + pre.logging { + border: 1px solid #c8c8c8; + margin: 0; + max-height: 250px; + overflow: auto; + padding: 6px; + white-space: initial; + word-break: break-all; + + div { + margin-bottom: 3px; + } + + .subElm { + margin-left: 10px; + } + + .keyValue { + font-weight: bold; + } + } + + .updateLogging { + margin-bottom: 2px; + margin-top: -22px; + text-align: right; + + a.ms-Link { + font-size: 12px; + } + } +} \ No newline at end of file diff --git a/samples/react-search/src/webparts/searchSpfx/SearchSpfxWebPart.ts b/samples/react-search/src/webparts/searchSpfx/SearchSpfxWebPart.ts index cea5fa7c6..028db0deb 100644 --- a/samples/react-search/src/webparts/searchSpfx/SearchSpfxWebPart.ts +++ b/samples/react-search/src/webparts/searchSpfx/SearchSpfxWebPart.ts @@ -10,6 +10,8 @@ import { PropertyPaneToggle } from '@microsoft/sp-client-preview'; +import { PropertyPaneLoggingField } from './PropertyPaneControls/PropertyPaneLoggingField'; + import ModuleLoader from '@microsoft/sp-module-loader'; import * as strings from 'mystrings'; @@ -19,15 +21,24 @@ import { IExternalTemplate, IScripts, IStyles } from './utils/ITemplates'; import { defer, IDeferred } from './utils/defer'; import { allTemplates } from './templates/TemplateLoader'; +// Import the search store, needed for logging the search requests +import searchStore from './flux/stores/searchStore'; + // Expose React to window -> required for external template loading require("expose?React!react"); export default class SearchSpfxWebPart extends BaseClientSideWebPart { private crntExternalTemplateUrl: string = ""; private crntExternalTemplate: IExternalTemplate = null; + private onChangeBinded: boolean = false; + private removeChangeBinding: NodeJS.Timer = null; public constructor(context: IWebPartContext) { super(context); + + // Bind this to the setLogging method + this.setLogging = this.setLogging.bind(this); + this.removeLogging = this.removeLogging.bind(this); } /** @@ -159,6 +170,41 @@ export default class SearchSpfxWebPart extends BaseClientSideWebPart 0) { + tokens.forEach((token) => { + // Check which token has been retrieved + if (token.toLowerCase().indexOf('today') !== -1) { + const dateValue = this.getDateValue(token); + restUrl = restUrl.replace(token, dateValue); + } + else if (token.toLowerCase().indexOf('user') !== -1) { + const userValue = this.getUserValue(token, context); + restUrl = restUrl.replace(token, userValue); + } + else { + switch (token.toLowerCase()) { + case "{site}": + restUrl = restUrl.replace(/{site}/ig, context.pageContext.web.absoluteUrl); + break; + case "{sitecollection}": + restUrl = restUrl.replace(/{sitecollection}/ig, _spPageContextInfo.siteAbsoluteUrl); + break; + case "{currentdisplaylanguage}": + restUrl = restUrl.replace(/{currentdisplaylanguage}/ig, context.pageContext.cultureInfo.currentCultureName); + break; + } + } + }); + } + + return restUrl; + } + + private getDateValue(token: string): string { + let dateValue = moment(); + // Check if we need to add days + if (token.toLowerCase().indexOf("{today+") !== -1) { + const daysVal = this.getDaysVal(token); + dateValue = dateValue.add(daysVal, 'day'); + } + // Check if we need to subtract days + if (token.toLowerCase().indexOf("{today-") !== -1) { + const daysVal = this.getDaysVal(token); + dateValue = dateValue.subtract(daysVal, 'day'); + } + return dateValue.format('YYYY-MM-DD'); + } + + private getDaysVal(token: string): number { + const tmpDays: string = token.substring(7, token.length - 1); + return parseInt(tmpDays) || 0; + } + + private getUserValue(token: string, context: IWebPartContext): string { + let userValue = '"' + context.pageContext.user.displayName + '"'; + + if (token.toLowerCase().indexOf("{user.") !== -1) { + const propVal = token.toLowerCase().substring(6, token.length - 1); + switch (propVal) { + case "name": + userValue = '"' + context.pageContext.user.displayName + '"'; + break; + case "email": + userValue = context.pageContext.user.email; + break; + } + } + + return userValue; + } +} \ No newline at end of file diff --git a/samples/react-search/src/webparts/searchSpfx/flux/stores/searchStore.ts b/samples/react-search/src/webparts/searchSpfx/flux/stores/searchStore.ts index b0e89d7be..7e07435df 100644 --- a/samples/react-search/src/webparts/searchSpfx/flux/stores/searchStore.ts +++ b/samples/react-search/src/webparts/searchSpfx/flux/stores/searchStore.ts @@ -1,17 +1,17 @@ import appDispatcher from '../dispatcher/appDispatcher'; import searchActionIDs from '../actions/searchActionIDs'; +import SearchTokenHelper from '../helpers/SearchTokenHelper'; import { EventEmitter } from 'events'; import { IWebPartContext } from '@microsoft/sp-client-preview'; import { ISearchResults, ICells, ICellValue } from '../../utils/ISearchResults'; -import { IPageContext } from '../../utils/IPageContext'; - -declare const _spPageContextInfo: IPageContext; const CHANGE_EVENT: string = 'change'; export class SearchStoreStatic extends EventEmitter { private _results: any[] = []; + private _url: string; + private _response: any; /** * @param {function} callback @@ -36,8 +36,8 @@ export class SearchStoreStatic extends EventEmitter { } public setSearchResults(crntResults: ICells[], fields: string): void { - const flds: string[] = fields.toLowerCase().split(','); if (crntResults.length > 0) { + const flds: string[] = fields.toLowerCase().split(','); const temp: any[] = []; crntResults.forEach((result) => { // Create a temp value @@ -73,19 +73,6 @@ export class SearchStoreStatic extends EventEmitter { }); } - /** - * @param {string} query - */ - public ReplaceTokens (query: string, context: IWebPartContext): string { - if (query.toLowerCase().indexOf("{site}") !== -1) { - query = query.replace(/{site}/ig, context.pageContext.web.absoluteUrl); - } - if (query.toLowerCase().indexOf("{sitecollection}") !== -1) { - query = query.replace(/{sitecollection}/ig, _spPageContextInfo.siteAbsoluteUrl); - } - return query; - } - /** * @param {string} value */ @@ -99,6 +86,18 @@ export class SearchStoreStatic extends EventEmitter { public isNull (value: any): boolean { return value === null || typeof value === "undefined"; } + + public setLoggingInfo(url: string, response: any) { + this._url = url; + this._response = response; + } + + public getLoggingInfo(): any { + return { + URL: this._url, + Response: this._response + }; + } } const searchStore: SearchStoreStatic = new SearchStoreStatic(); @@ -106,9 +105,10 @@ const searchStore: SearchStoreStatic = new SearchStoreStatic(); appDispatcher.register((action) => { switch (action.actionType) { case searchActionIDs.SEARCH_GET: + const tokenHelper = new SearchTokenHelper(); let url: string = action.context.pageContext.web.absoluteUrl + "/_api/search/query?querytext="; // Check if a query is provided - url += !searchStore.isEmptyString(action.query) ? `'${searchStore.ReplaceTokens(action.query, action.context)}'` : "'*'"; + url += !searchStore.isEmptyString(action.query) ? `'${tokenHelper.replaceTokens(action.query, action.context)}'` : "'*'"; // Check if there are fields provided url += '&selectproperties='; url += !searchStore.isEmptyString(action.fields) ? `'${action.fields}'` : "'path,title'"; @@ -121,20 +121,28 @@ appDispatcher.register((action) => { url += "&clienttype='ContentSearchRegular'"; searchStore.GetSearchData(action.context, url).then((res: ISearchResults) => { + searchStore.setLoggingInfo(url, res); + let resultsRetrieved = false; if (res !== null) { if (typeof res.PrimaryQueryResult !== 'undefined') { if (typeof res.PrimaryQueryResult.RelevantResults !== 'undefined') { if (typeof res.PrimaryQueryResult.RelevantResults !== 'undefined') { if (typeof res.PrimaryQueryResult.RelevantResults.Table !== 'undefined') { if (typeof res.PrimaryQueryResult.RelevantResults.Table.Rows !== 'undefined') { + resultsRetrieved = true; searchStore.setSearchResults(res.PrimaryQueryResult.RelevantResults.Table.Rows, action.fields); - searchStore.emitChange(); } } } } } } + + // Reset the store its search result set on error + if (!resultsRetrieved) { + searchStore.setSearchResults([], null); + } + searchStore.emitChange(); }); break; diff --git a/samples/react-search/src/webparts/searchSpfx/loc/en-us.js b/samples/react-search/src/webparts/searchSpfx/loc/en-us.js index a22474f68..0a41a1cba 100644 --- a/samples/react-search/src/webparts/searchSpfx/loc/en-us.js +++ b/samples/react-search/src/webparts/searchSpfx/loc/en-us.js @@ -8,8 +8,12 @@ define([], function() { "FieldsTemplateLabel": "Choose the template you want to use for rendering the results", "FieldsMaxResults": "Number of results to render", "FieldsSorting": "Sorting (MP:ascending or descending) - example: lastmodifiedtime:ascending,author:descending", - "QueryInfoDescription": "You can make use of following tokens: {Site} - {SiteCollection}", + "QueryInfoDescription": "You can make use of following tokens: {Site} - {SiteCollection} - {Today} or {Today+NR} or {Today-NR} - {CurrentDisplayLanguage} - {User}, {User.Name}, {User.Email}", "FieldsExternalLabel": "Do you want to use an external template?", - "FieldsExternalTempLabel": "Specify the URL of the external template" + "FieldsExternalTempLabel": "Specify the URL of the external template", + "TemplateGroupName": "Template settings", + "LoggingGroupName": "Logging pane", + "LoggingFieldLabel": "Logging search API calls", + "LoggingFieldDescription": "This field logs all search API calls" } }); \ No newline at end of file diff --git a/samples/react-search/src/webparts/searchSpfx/loc/mystrings.d.ts b/samples/react-search/src/webparts/searchSpfx/loc/mystrings.d.ts index b96bf762f..ba7e576ed 100644 --- a/samples/react-search/src/webparts/searchSpfx/loc/mystrings.d.ts +++ b/samples/react-search/src/webparts/searchSpfx/loc/mystrings.d.ts @@ -10,6 +10,10 @@ declare interface IStrings { QueryInfoDescription: string; FieldsExternalLabel: string; FieldsExternalTempLabel: string; + TemplateGroupName: string; + LoggingGroupName: string; + LoggingFieldLabel: string; + LoggingFieldDescription: string; } declare module 'mystrings' {