Search wp updates with various new functionalities (#27)

This commit is contained in:
Elio Struyf 2016-10-06 09:44:32 +02:00 committed by Vesa Juvonen
parent 67795ba337
commit b3812d76c4
11 changed files with 448 additions and 30 deletions

View File

@ -22,6 +22,7 @@ react-search-wp|Elio Struyf (MVP, Ventigrate, [@eliostruyf](https://twitter.com/
Version|Date|Comments Version|Date|Comments
-------|----|-------- -------|----|--------
0.0.4|September 08, 2016|Initial release 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 ## 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.**
@ -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. 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**
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. 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.

View File

@ -1,13 +1,13 @@
{ {
"name": "search-wp-spfx", "name": "search-wp-spfx",
"version": "0.0.4", "version": "0.0.5",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
}, },
"dependencies": { "dependencies": {
"@microsoft/sp-client-base": "~0.2.0", "@microsoft/sp-client-base": "~0.3.0",
"@microsoft/sp-client-preview": "~0.2.0", "@microsoft/sp-client-preview": "~0.4.0",
"flux": "^2.1.1", "flux": "^2.1.1",
"moment": "^2.14.1", "moment": "^2.14.1",
"office-ui-fabric-react": "0.36.0", "office-ui-fabric-react": "0.36.0",
@ -15,9 +15,9 @@
"react-dom": "0.14.8" "react-dom": "0.14.8"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/sp-build-web": "~0.5.0", "@microsoft/sp-build-web": "~0.6.0",
"@microsoft/sp-module-interfaces": "~0.2.0", "@microsoft/sp-module-interfaces": "~0.3.0",
"@microsoft/sp-webpart-workbench": "~0.2.0", "@microsoft/sp-webpart-workbench": "~0.4.0",
"expose-loader": "^0.7.1", "expose-loader": "^0.7.1",
"gulp": "~3.9.1" "gulp": "~3.9.1"
}, },
@ -26,4 +26,4 @@
"clean": "gulp nuke", "clean": "gulp nuke",
"test": "gulp test" "test": "gulp test"
} }
} }

View File

@ -0,0 +1,6 @@
export interface IPropertyPaneLoggingFieldProps {
label?: string;
description?: string;
value: any;
retrieve?: Function;
}

View File

@ -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<IPropertyPaneLoggingFieldPropsInternal> {
// 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<IPropertyPaneLoggingFieldHostProps> = 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<IPropertyPaneLoggingFieldPropsInternal> {
// 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);
}

View File

@ -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<IPropertyPaneLoggingFieldHostProps, IPropertyPaneLoggingFieldState> {
/**
* @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(<div key={k} className={subClass}><span className="keyValue">{k}</span>: object {this.renderValue(val[k], "subElm")}</div>);
} else {
output.push(<div key={k} className={subClass}><span className="keyValue">{k}</span>: {val[k]}</div>);
}
}
return output;
}
/**
* @function
* Renders the logging field control
*/
public render(): JSX.Element {
const valToRender = this.renderValue(this.state.logging);
//Renders content
return (
<div className="loggingField">
<Label>{this.props.label}</Label>
{
(() => {
if (typeof this.props.retrieve !== 'undefined') {
return <div className="updateLogging"><a className="ms-Link" onClick={this.getLogging}>Update logging</a></div>;
}
})()
}
<pre className="logging">{valToRender}</pre>
{
(() => {
if (typeof this.props.description !== 'undefined') {
return <span className="ms-TextField-description">{this.props.description}</span>;
}
})()
}
</div>
);
}
}

View File

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

View File

@ -10,6 +10,8 @@ import {
PropertyPaneToggle PropertyPaneToggle
} from '@microsoft/sp-client-preview'; } from '@microsoft/sp-client-preview';
import { PropertyPaneLoggingField } from './PropertyPaneControls/PropertyPaneLoggingField';
import ModuleLoader from '@microsoft/sp-module-loader'; import ModuleLoader from '@microsoft/sp-module-loader';
import * as strings from 'mystrings'; import * as strings from 'mystrings';
@ -19,15 +21,24 @@ import { IExternalTemplate, IScripts, IStyles } from './utils/ITemplates';
import { defer, IDeferred } from './utils/defer'; import { defer, IDeferred } from './utils/defer';
import { allTemplates } from './templates/TemplateLoader'; 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 // Expose React to window -> required for external template loading
require("expose?React!react"); require("expose?React!react");
export default class SearchSpfxWebPart extends BaseClientSideWebPart<ISearchSpfxWebPartProps> { export default class SearchSpfxWebPart extends BaseClientSideWebPart<ISearchSpfxWebPartProps> {
private crntExternalTemplateUrl: string = ""; private crntExternalTemplateUrl: string = "";
private crntExternalTemplate: IExternalTemplate = null; private crntExternalTemplate: IExternalTemplate = null;
private onChangeBinded: boolean = false;
private removeChangeBinding: NodeJS.Timer = null;
public constructor(context: IWebPartContext) { public constructor(context: IWebPartContext) {
super(context); 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<ISearchSpfx
} }
} }
protected onPropertyPaneRendered(): void {
// Clear remove binding timeout. This is necessary if user applied a new configuration.
if (this.removeChangeBinding !== null) {
clearTimeout(this.removeChangeBinding);
this.removeChangeBinding = null;
}
// Check if there is a change binding in place
if (!this.onChangeBinded) {
this.onChangeBinded = true;
searchStore.addChangeListener(this.setLogging);
}
}
// Will probably be renamed to onPropertyConfigurationComplete in the next drop
protected onPropertyPaneConfigurationComplete() {
// Remove the change binding
this.removeChangeBinding = setTimeout(this.removeLogging, 500);
}
// protected onPropertyPaneConfigurationStart() {
// // Will probably be deleted in the next drop
// console.log('onPropertyPaneConfigurationStart');
// }
// protected onAfterPropertyPaneChangesApplied() {
// // Will probably be deleted in the next drop
// console.log('onAfterPropertyPaneChangesApplied');
// }
// Will probably be added in the next drop
// protected onPropertyPaneSave() {
// console.log('onPropertyPaneSave');
// }
/** /**
* Property pane settings * Property pane settings
*/ */
@ -200,17 +246,57 @@ export default class SearchSpfxWebPart extends BaseClientSideWebPart<ISearchSpfx
}), }),
PropertyPaneTextField('sorting', { PropertyPaneTextField('sorting', {
label: strings.FieldsSorting label: strings.FieldsSorting
}), })
]
}, {
groupName: strings.TemplateGroupName,
groupFields: [
PropertyPaneToggle('external', { PropertyPaneToggle('external', {
label: strings.FieldsExternalLabel label: strings.FieldsExternalLabel
}), }),
templateProperty templateProperty
] ]
}] }, {
groupName: strings.LoggingGroupName,
groupFields: [
PropertyPaneLoggingField({
label: strings.LoggingFieldLabel,
description: strings.LoggingFieldDescription,
value: searchStore.getLoggingInfo(),
retrieve: this.getLogging
})
]
}],
displayGroupsAsAccordion: true
}] }]
}; };
} }
/**
* Function to retrieve the logging value from the store
*/
private getLogging(): any {
return searchStore.getLoggingInfo();
}
/**
* Function to refresh the property pane when a change is retrieved from the store
*/
private setLogging(): void {
// Refresh the property pane when search rest call is completed
this.configureStart(true);
}
/**
* Function to remove the change binding when property pane is closed
*/
private removeLogging(): void {
if (this.onChangeBinded) {
this.onChangeBinded = false;
searchStore.removeChangeListener(this.setLogging);
}
}
/** /**
* Prevent from changing the query on typing * Prevent from changing the query on typing
*/ */

View File

@ -0,0 +1,83 @@
import { IWebPartContext } from '@microsoft/sp-client-preview';
import { IPageContext } from '../../utils/IPageContext';
import * as moment from 'moment';
declare const _spPageContextInfo: IPageContext;
export default class SearchTokenHelper {
private regexVal: RegExp = /\{[^\{]*?\}/gi;
constructor() {}
public replaceTokens(restUrl: string, context: IWebPartContext): string {
const tokens = restUrl.match(this.regexVal);
if (tokens !== null && tokens.length > 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;
}
}

View File

@ -1,17 +1,17 @@
import appDispatcher from '../dispatcher/appDispatcher'; import appDispatcher from '../dispatcher/appDispatcher';
import searchActionIDs from '../actions/searchActionIDs'; import searchActionIDs from '../actions/searchActionIDs';
import SearchTokenHelper from '../helpers/SearchTokenHelper';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { IWebPartContext } from '@microsoft/sp-client-preview'; import { IWebPartContext } from '@microsoft/sp-client-preview';
import { ISearchResults, ICells, ICellValue } from '../../utils/ISearchResults'; import { ISearchResults, ICells, ICellValue } from '../../utils/ISearchResults';
import { IPageContext } from '../../utils/IPageContext';
declare const _spPageContextInfo: IPageContext;
const CHANGE_EVENT: string = 'change'; const CHANGE_EVENT: string = 'change';
export class SearchStoreStatic extends EventEmitter { export class SearchStoreStatic extends EventEmitter {
private _results: any[] = []; private _results: any[] = [];
private _url: string;
private _response: any;
/** /**
* @param {function} callback * @param {function} callback
@ -36,8 +36,8 @@ export class SearchStoreStatic extends EventEmitter {
} }
public setSearchResults(crntResults: ICells[], fields: string): void { public setSearchResults(crntResults: ICells[], fields: string): void {
const flds: string[] = fields.toLowerCase().split(',');
if (crntResults.length > 0) { if (crntResults.length > 0) {
const flds: string[] = fields.toLowerCase().split(',');
const temp: any[] = []; const temp: any[] = [];
crntResults.forEach((result) => { crntResults.forEach((result) => {
// Create a temp value // 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 * @param {string} value
*/ */
@ -99,6 +86,18 @@ export class SearchStoreStatic extends EventEmitter {
public isNull (value: any): boolean { public isNull (value: any): boolean {
return value === null || typeof value === "undefined"; 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(); const searchStore: SearchStoreStatic = new SearchStoreStatic();
@ -106,9 +105,10 @@ const searchStore: SearchStoreStatic = new SearchStoreStatic();
appDispatcher.register((action) => { appDispatcher.register((action) => {
switch (action.actionType) { switch (action.actionType) {
case searchActionIDs.SEARCH_GET: case searchActionIDs.SEARCH_GET:
const tokenHelper = new SearchTokenHelper();
let url: string = action.context.pageContext.web.absoluteUrl + "/_api/search/query?querytext="; let url: string = action.context.pageContext.web.absoluteUrl + "/_api/search/query?querytext=";
// Check if a query is provided // 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 // Check if there are fields provided
url += '&selectproperties='; url += '&selectproperties=';
url += !searchStore.isEmptyString(action.fields) ? `'${action.fields}'` : "'path,title'"; url += !searchStore.isEmptyString(action.fields) ? `'${action.fields}'` : "'path,title'";
@ -121,20 +121,28 @@ appDispatcher.register((action) => {
url += "&clienttype='ContentSearchRegular'"; url += "&clienttype='ContentSearchRegular'";
searchStore.GetSearchData(action.context, url).then((res: ISearchResults) => { searchStore.GetSearchData(action.context, url).then((res: ISearchResults) => {
searchStore.setLoggingInfo(url, res);
let resultsRetrieved = false;
if (res !== null) { if (res !== null) {
if (typeof res.PrimaryQueryResult !== 'undefined') { if (typeof res.PrimaryQueryResult !== 'undefined') {
if (typeof res.PrimaryQueryResult.RelevantResults !== 'undefined') { if (typeof res.PrimaryQueryResult.RelevantResults !== '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 !== 'undefined') {
if (typeof res.PrimaryQueryResult.RelevantResults.Table.Rows !== 'undefined') { if (typeof res.PrimaryQueryResult.RelevantResults.Table.Rows !== 'undefined') {
resultsRetrieved = true;
searchStore.setSearchResults(res.PrimaryQueryResult.RelevantResults.Table.Rows, action.fields); 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; break;

View File

@ -8,8 +8,12 @@ define([], function() {
"FieldsTemplateLabel": "Choose the template you want to use for rendering the results", "FieldsTemplateLabel": "Choose the template you want to use for rendering the results",
"FieldsMaxResults": "Number of results to render", "FieldsMaxResults": "Number of results to render",
"FieldsSorting": "Sorting (MP:ascending or descending) - example: lastmodifiedtime:ascending,author:descending", "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?", "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"
} }
}); });

View File

@ -10,6 +10,10 @@ declare interface IStrings {
QueryInfoDescription: string; QueryInfoDescription: string;
FieldsExternalLabel: string; FieldsExternalLabel: string;
FieldsExternalTempLabel: string; FieldsExternalTempLabel: string;
TemplateGroupName: string;
LoggingGroupName: string;
LoggingFieldLabel: string;
LoggingFieldDescription: string;
} }
declare module 'mystrings' { declare module 'mystrings' {