Added the files to the new repository

This commit is contained in:
Simon-Pierre Plante 2017-04-21 01:48:22 -04:00
commit 2b0d60e069
73 changed files with 11896 additions and 0 deletions

25
.editorconfig Normal file
View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# change these settings to your own preference
indent_style = space
indent_size = 2
# we recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[{package,bower}.json]
indent_style = space
indent_size = 2

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
solution
temp
*.sppkg
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts

14
.npmignore Normal file
View File

@ -0,0 +1,14 @@
# Folders
.vscode
coverage
node_modules
sharepoint
src
temp
# Files
*.csproj
.git*
.yo-rc.json
gulpfile.js
tsconfig.json

8
.yo-rc.json Normal file
View File

@ -0,0 +1,8 @@
{
"@microsoft/generator-sharepoint": {
"libraryName": "content-query-webpart",
"framework": "react",
"version": "1.0.0",
"libraryId": "00406271-0276-406f-9666-512623eb6709"
}
}

31
README.md Normal file
View File

@ -0,0 +1,31 @@
## React Content Query WebPart
This is where you include your WebPart documentation.
### Building the code
```bash
git clone the repo
npm i
npm i -g gulp
gulp
```
This package produces the following:
* lib/* - intermediate-stage commonjs build artifacts
* dist/* - the bundled script, along with other resources
* deploy/* - all resources which should be uploaded to a CDN.
### Build options
gulp clean - TODO
gulp test - TODO
gulp serve - TODO
gulp bundle - TODO
gulp package-solution - TODO
### TO DOs
- Add support for URL field types in CAML query

13
config/config.json Normal file
View File

@ -0,0 +1,13 @@
{
"entries": [
{
"entry": "./lib/webparts/contentQuery/ContentQueryWebPart.js",
"manifest": "./src/webparts/contentQuery/ContentQueryWebPart.manifest.json",
"outputPath": "./dist/content-query.bundle.js"
}
],
"externals": {},
"localizedResources": {
"contentQueryStrings": "webparts/contentQuery/loc/{locale}.js"
}
}

3
config/copy-assets.json Normal file
View File

@ -0,0 +1,3 @@
{
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,6 @@
{
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "react-content-query",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,10 @@
{
"solution": {
"name": "React Content Query",
"id": "00406271-0276-406f-9666-512623eb6709",
"version": "1.0.0.0"
},
"paths": {
"zippedPackage": "solution/react-content-query-webpart.sppkg"
}
}

9
config/serve.json Normal file
View File

@ -0,0 +1,9 @@
{
"port": 4321,
"initialPage": "https://localhost:5432/workbench",
"https": true,
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

46
config/tslint.json Normal file
View File

@ -0,0 +1,46 @@
{
// Display errors as warnings
"displayAsWarning": true,
// The TSLint task may have been configured with several custom lint rules
// before this config file is read (for example lint rules from the tslint-microsoft-contrib
// project). If true, this flag will deactivate any of these rules.
"removeExistingRules": true,
// When true, the TSLint task is configured with some default TSLint "rules.":
"useDefaultConfigAsBase": false,
// Since removeExistingRules=true and useDefaultConfigAsBase=false, there will be no lint rules
// which are active, other than the list of rules below.
"lintConfig": {
// Opt-in to Lint rules which help to eliminate bugs in JavaScript
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": true,
"no-construct": false,
"no-duplicate-case": true,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-unused-imports": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"valid-typeof": true,
"variable-name": false,
"whitespace": false,
"prefer-const": false
}
}
}

View File

@ -0,0 +1,3 @@
{
"cdnBasePath": "https://publiccdn.sharepointonline.com/spptechnologies.sharepoint.com/110700492eeea162ee5bad0f35b1f0061ded8bf436ce0199efe2a4d24109e1c0df1ec594/react-content-query"
}

17
gulpfile.js Normal file
View File

@ -0,0 +1,17 @@
'use strict';
const gulp = require('gulp');
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
********************************************************************************************/
build.configureWebpack.mergeConfig({
additionalConfiguration: (generatedConfiguration) => {
generatedConfiguration.resolve.alias = { handlebars: 'handlebars/dist/handlebars.min.js' };
return generatedConfiguration;
}
});
build.initialize(gulp);

7971
npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "react-content-query",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=0.10.0"
},
"dependencies": {
"@microsoft/sp-client-base": "~1.0.0",
"@microsoft/sp-core-library": "~1.0.0",
"@microsoft/sp-webpart-base": "~1.0.0",
"@types/handlebars": "^4.0.32",
"@types/react": "0.14.46",
"@types/react-addons-shallow-compare": "0.14.17",
"@types/react-addons-test-utils": "0.14.15",
"@types/react-addons-update": "0.14.14",
"@types/react-dom": "0.14.18",
"@types/webpack-env": ">=1.12.1 <1.14.0",
"handlebars": "^4.0.6",
"moment": "^2.18.1",
"office-ui-fabric-react": "1.14.3",
"react": "15.4.2",
"react-dom": "15.4.2"
},
"devDependencies": {
"@microsoft/sp-build-web": "https://registry.npmjs.org/@microsoft/sp-build-web/-/sp-build-web-1.0.0.tgz",
"@microsoft/sp-module-interfaces": "~1.0.0",
"@microsoft/sp-webpart-workbench": "~1.0.0",
"@types/chai": ">=3.4.34 <3.6.0",
"@types/microsoft-ajax": "0.0.31",
"@types/mocha": ">=2.2.33 <2.6.0",
"@types/sharepoint": "^2013.1.4",
"gulp": "~3.9.1"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
}
}

View File

@ -0,0 +1,16 @@
export class ContentQueryConstants {
/**************************************************************
* WebPart Properties
**************************************************************/
public static readonly propertyWebUrl = "webUrl";
public static readonly propertyListTitle = "listTitle";
public static readonly propertyOrderBy = "orderBy";
public static readonly propertOrderByDirection = "orderByDirection";
public static readonly propertyLimitEnabled = "limitEnabled";
public static readonly propertyItemLimit = "itemLimit";
public static readonly propertyFilters = "filters";
public static readonly propertyViewFields = "viewFields";
public static readonly propertyTemplateUrl = "templateUrl";
}

View File

@ -0,0 +1,293 @@
import * as moment from 'moment';
import { Text } from '@microsoft/sp-core-library';
import { isEmpty } from '@microsoft/sp-lodash-subset';
import { IPersonaProps, ITag } from 'office-ui-fabric-react';
import { IQuerySettings } from '../../webparts/contentQuery/components/IQuerySettings';
import { IQueryFilter } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/IQueryFilter';
import { QueryFilterOperator } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/QueryFilterOperator';
import { QueryFilterJoin } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/QueryFilterJoin';
import { QueryFilterFieldType } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/QueryFilterFieldType';
export class CamlQueryHelper {
/*************************************************************************************************
* Generates a full CAML query based on the provided IQuerySettings
* @param querySettings : A IQuerySettings object required for generating the CAML query
*************************************************************************************************/
public static generateCamlQuery(querySettings:IQuerySettings): string {
let query = '';
// Generates the <Where /> part
if(querySettings.filters && !isEmpty(querySettings.filters)) {
query += Text.format('<Where>{0}</Where>', this.generateFilters(querySettings.filters));
}
// Generates the <OrderBy /> part
if(querySettings.orderBy && !isEmpty(querySettings.orderBy)) {
let isAscending = querySettings.orderByDirection == 'desc' ? 'FALSE' : 'TRUE';
query += Text.format("<OrderBy><FieldRef Name='{0}' Ascending='{1}' /></OrderBy>", querySettings.orderBy, isAscending);
}
// Wraps the <Where /> and <OrderBy /> into a <Query /> tag
query = Text.format('<Query>{0}</Query>', query);
// Generates the <RowLimit /> part
if(querySettings.limitEnabled) {
query += Text.format('<RowLimit>{0}</RowLimit>', querySettings.itemLimit);
}
// Generates the <ViewFields /> part
if(querySettings.viewFields && !isEmpty(querySettings.viewFields)) {
query += Text.format('<ViewFields>{0}</ViewFields>', querySettings.viewFields.map(field => Text.format("<FieldRef Name='{0}' />", field)).join(''));
}
// Wraps the everything into a final <View /> tag
query = Text.format('<View>{0}</View>', query);
return query;
}
/*************************************************************************************************
* Generates the CAML filters based on the specified array of IQueryFilter objects
* @param filters : The filters that needs to be converted to a CAML string
*************************************************************************************************/
private static generateFilters(filters:IQueryFilter[]): string {
// Store the generic filter format for later use
let query = '';
let filterXml = '<{0}><FieldRef Name="{1}" /><Value {2} Type="{3}">{4}</Value></{0}>';
// Appends a CAML node for each filter
let itemCount = 0;
for(let filter of filters.reverse()) {
itemCount++;
let specialAttribute = '';
// Sets the special attribute if needed
if(filter.field.type == QueryFilterFieldType.Datetime) {
specialAttribute = 'IncludeTimeValue="' + filter.includeTime + '"';
}
// If it's a <IsNull /> or <IsNotNull> filter
if(filter.operator == QueryFilterOperator.IsNull || filter.operator == QueryFilterOperator.IsNotNull) {
filterXml = '<{0}><FieldRef Name="{1}" /></{0}>';
query += Text.format(filterXml, QueryFilterOperator[filter.operator], filter.field.internalName);
}
// If it's a taxonomy filter
else if (filter.field.type == QueryFilterFieldType.Taxonomy) {
query += this.generateTaxonomyFilter(filter);
}
// If it's a user filter
else if (filter.field.type == QueryFilterFieldType.User) {
query += this.generateUserFilter(filter);
}
// If it's any other kind of filter (Text, DateTime, Lookup, Number etc...)
else {
let valueType = (filter.field.type == QueryFilterFieldType.Lookup ? QueryFilterFieldType[QueryFilterFieldType.Text] : QueryFilterFieldType[filter.field.type]);
query += Text.format(filterXml, QueryFilterOperator[filter.operator], filter.field.internalName, specialAttribute, valueType, this.formatFilterValue(filter));
}
// Appends the Join tags if needed
if (itemCount >= 2) {
let logicalJoin = QueryFilterJoin[filter.join];
query = Text.format("<{0}>", logicalJoin) + query;
query += Text.format("</{0}>", logicalJoin);
}
}
return query;
}
/*************************************************************************************************
* Generates a valid CAML filter string based on the specified taxonomy filter
* @param filter : The taxonomy filter that needs to be formatted into a CAML filter string
*************************************************************************************************/
private static generateTaxonomyFilter(filter:IQueryFilter): string
{
let filterOutput = '';
let filterTerms = filter.value as ITag[];
if(isEmpty(filter.value)) {
return '';
}
else if (filter.operator == QueryFilterOperator.ContainsAny || filterTerms == null) {
let values = filterTerms != null ? filterTerms.map(x => Text.format("<Value Type='Integer'>{0}</Value>", x.key)).join('') : '';
filterOutput = Text.format("<In><FieldRef Name='{0}' LookupId='TRUE' /><Values>{1}</Values></In>", filter.field.internalName, values);
}
else if (filter.operator == QueryFilterOperator.ContainsAll) {
let taxFilters: IQueryFilter[] = [];
for(let term of filterTerms) {
let termValue:ITag[] = [ term ];
let taxFilter:IQueryFilter = {
field: filter.field,
value: termValue,
join: QueryFilterJoin.And,
operator: QueryFilterOperator.ContainsAny
};
taxFilters.push(taxFilter);
}
filterOutput = this.generateFilters(taxFilters);
}
return filterOutput;
}
/*************************************************************************************************
* Generates a valid CAML filter string based on the specified user filter
* @param filter : The user filter that needs to be formatted into a CAML filter string
*************************************************************************************************/
private static generateUserFilter(filter:IQueryFilter): string
{
let filterOutput = '';
let filterUsers = filter.value as IPersonaProps[];
if(isEmpty(filter.value)) {
return '';
}
else if(filter.me) {
filterOutput = Text.format("<In><FieldRef Name='{0}' LookupId='TRUE' /><Values><Value Type='Integer'><UserID /></Value></Values></In>", filter.field.internalName);
}
else if (filter.operator == QueryFilterOperator.ContainsAny || filterUsers == null)
{
let values = filterUsers != null ? filterUsers.map(x => Text.format("<Value Type='Integer'>{0}</Value>", x.value)).join('') : '';
filterOutput = Text.format("<In><FieldRef Name='{0}' LookupId='TRUE' /><Values>{1}</Values></In>", filter.field.internalName, values);
}
else if (filter.operator == QueryFilterOperator.ContainsAll)
{
let userFilters: IQueryFilter[] = [];
for(let user of filterUsers) {
let userValue:IPersonaProps[] = [ user ];
let userFilter:IQueryFilter = {
field: filter.field,
value: userValue,
join: QueryFilterJoin.And,
operator: QueryFilterOperator.ContainsAny
};
userFilters.push(userFilter);
}
filterOutput = this.generateFilters(userFilters);
}
return filterOutput;
}
/*************************************************************************************************
* Returns the value of the specified filter correctly formatted based on its type of value
* @param filter : The filter that needs its value to be formatted
*************************************************************************************************/
private static formatFilterValue(filter:IQueryFilter): string
{
let filterValue = "";
if(filter.field.type == QueryFilterFieldType.Datetime) {
if(filter.expression != null && !isEmpty(filter.expression)) {
filterValue = this.formatDateExpressionFilterValue(filter.expression);
}
else {
filterValue = this.formatDateFilterValue(filter.value as string);
}
}
else {
filterValue = this.formatTextFilterValue(filter.value as string);
}
return filterValue;
}
/*************************************************************************************************
* Converts the specified serialized ISO date into the required string format
* @param dateValue : A valid ISO 8601 date string
*************************************************************************************************/
private static formatDateFilterValue(dateValue:string): string {
let date = moment(dateValue, moment.ISO_8601, true);
if(date.isValid()) {
dateValue = date.format("YYYY-MM-DDTHH:mm:ss\\Z");
}
return dateValue || '';
}
/*************************************************************************************************
* Replaces any "[Today]" or "[Today] +/- [digit]" expression by it's actual value
* @param filterValue : The filter value
*************************************************************************************************/
private static formatDateExpressionFilterValue(filterValue: string): string {
// Replaces any "[Today] +/- [digit]" expression
let regex = new RegExp("\\[Today\\]\\s*[\\+-]\\s*\\[{0,1}\\d{1,}\\]{0,1}");
let results = regex.exec(filterValue);
if(results != null) {
for(let result of results) {
let operator = result.indexOf('+') > 0 ? '+' : '-';
let addOrRemove = operator == '+' ? 1 : -1;
let operatorSplit = result.split(operator);
let digit = parseInt(operatorSplit[operatorSplit.length - 1].replace("[", "").replace("]", "").trim()) * addOrRemove;
let dt = new Date();
dt.setDate(dt.getDate() + digit);
let formattedDate = moment(dt).format("YYYY-MM-DDTHH:mm:ss\\Z");
filterValue = filterValue.replace(result, formattedDate);
}
}
// Replaces any "[Today]" expression by it's actual value
let formattedDate = moment(new Date()).format("YYYY-MM-DDTHH:mm:ss\\Z");
filterValue = filterValue.replace("[Today]", formattedDate);
return filterValue;
}
/*************************************************************************************************
* Formats the specified text filter value
* @param textValue : The text filter value which needs to be formatted
*************************************************************************************************/
private static formatTextFilterValue(textValue:string): string {
let regex = new RegExp("\\[PageQueryString:[A-Za-z0-9_-]*\\]");
let results = regex.exec(textValue);
if(results != null) {
for(let result of results) {
let parameter = result.substring(17, result.length - 1);
textValue = textValue.replace(result, this.getUrlParameter(parameter));
}
}
return textValue != null ? textValue : '';
}
/*************************************************************************************************
* Returns the value of the query string parameter with the specified name
* @param name : The name of the query string parameter
* @param url : Optionnaly, the specific url to use instead of the current url
*************************************************************************************************/
private static getUrlParameter(name: string, url?: string): string {
if (!url) {
url = window.location.href;
}
name = name.replace(/[\[\]]/g, "\\$&");
var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, " "));
}
}

View File

@ -0,0 +1,563 @@
import * as strings from 'contentQueryStrings';
import { IDropdownOption, IPersonaProps, ITag } from 'office-ui-fabric-react';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { isEmpty } from '@microsoft/sp-lodash-subset';
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { Text, Log } from '@microsoft/sp-core-library';
import { IContentQueryService } from './IContentQueryService';
import { IQueryFilterField } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/IQueryFilterField';
import { QueryFilterFieldType } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/QueryFilterFieldType';
import { IChecklistItem } from '../../controls/PropertyPaneAsyncChecklist/components/AsyncChecklist/IChecklistItem';
import { IContentQueryTemplateContext } from '../../webparts/contentQuery/components/IContentQueryTemplateContext';
import { IQuerySettings } from '../../webparts/contentQuery/components/IQuerySettings';
import { CamlQueryHelper } from '../helpers/CamlQueryHelper';
import { ListService } from './ListService';
import { SearchService } from './SearchService';
import { PeoplePickerService } from './PeoplePickerService';
import { TaxonomyService } from './TaxonomyService';
export class ContentQueryService implements IContentQueryService {
private readonly logSource = "ContentQueryService.ts";
/***************************************************************************
* The page context and http clients used for performing REST calls
***************************************************************************/
private context: IWebPartContext;
private spHttpClient: SPHttpClient;
/***************************************************************************
* The different services used to perform REST calls
***************************************************************************/
private listService: ListService;
private searchService: SearchService;
private peoplePickerService: PeoplePickerService;
private taxonomyService: TaxonomyService;
/***************************************************************************
* Stores the first async calls locally to avoid useless redundant calls
***************************************************************************/
private webUrlOptions: IDropdownOption[];
private listTitleOptions: IDropdownOption[];
private orderByOptions: IDropdownOption[];
private filterFields: IQueryFilterField[];
private viewFields: IChecklistItem[];
/***************************************************************************
* Constructor
* @param context : A IWebPartContext for logging and page context
* @param spHttpClient : A SPHttpClient for performing SharePoint specific requests
***************************************************************************/
constructor(context: IWebPartContext, spHttpClient: SPHttpClient) {
Log.verbose(this.logSource, "Initializing a new IContentQueryService instance...", context.serviceScope);
this.context = context;
this.spHttpClient = spHttpClient;
this.listService = new ListService(this.spHttpClient);
this.searchService = new SearchService(this.spHttpClient);
this.peoplePickerService = new PeoplePickerService(this.spHttpClient);
this.taxonomyService = new TaxonomyService(this.spHttpClient);
}
/**************************************************************************************************
* Gets the available webs for the current user
**************************************************************************************************/
public getTemplateContext(querySettings: IQuerySettings, callTimeStamp: number): Promise<IContentQueryTemplateContext> {
Log.verbose(this.logSource, Text.format("Getting template context for request with queue number {0}...", callTimeStamp), this.context.serviceScope);
return new Promise<IContentQueryTemplateContext>((resolve,reject) => {
// Initializes the base template context
let templateContext:IContentQueryTemplateContext = {
pageContext: this.context.pageContext,
items: [],
accessDenied: false,
webNotFound: false,
callTimeStamp: callTimeStamp
};
// Builds the CAML query based on the webpart settings
let query = CamlQueryHelper.generateCamlQuery(querySettings);
Log.info(this.logSource, Text.format("Generated CAML query {0}...", query), this.context.serviceScope);
// Queries the list with the generated caml query
this.listService.getListItemsByQuery(querySettings.webUrl, querySettings.listTitle, query)
.then((data: any) => {
// Updates the template context with the normalized query results
let normalizedResults = this.normalizeQueryResults(data.value, querySettings.viewFields);
templateContext.items = normalizedResults;
resolve(templateContext);
})
.catch((error) => {
// If it fails because previously configured web/list isn't accessible for current user
if(error.status === 403) {
// Still resolve with accessDenied=true so the handlebar template can decide what to render in that case
templateContext.accessDenied = true;
resolve(templateContext);
}
// If it fails because previously configured web/list doesn't exist anymore
else if(error.status === 404) {
// Still resolve with webNotFound=true so the handlebar template can decide what to render in that case
templateContext.webNotFound = true;
resolve(templateContext);
}
// If it fails for any other reason, reject with the error message
else {
let errorMessage: string = error.statusText ? error.statusText : error;
reject(errorMessage);
}
}
);
});
}
/**************************************************************************************************
* Executes an HTTP request against the specified file and returns a promise with it's content
* @param fileUrl : The url of the file
**************************************************************************************************/
public getFileContent(fileUrl: string): Promise<string> {
Log.verbose(this.logSource, Text.format("Getting content for file with url '{0}'...", fileUrl), this.context.serviceScope);
return new Promise<string>((resolve,reject) => {
this.spHttpClient.get(fileUrl, SPHttpClient.configurations.v1).then((response: SPHttpClientResponse) => {
if(response.ok) {
resolve(response.text());
}
else {
reject(response.statusText);
}
})
.catch((error) => {
reject(error);
});
});
}
/**************************************************************************************************
* Gets the available webs for the current user
**************************************************************************************************/
public getWebUrlOptions(): Promise<IDropdownOption[]> {
Log.verbose(this.logSource, "Loading dropdown options for toolpart property 'Web Url'...", this.context.serviceScope);
// Resolves the already loaded data if available
if(this.webUrlOptions) {
return Promise.resolve(this.webUrlOptions);
}
// Otherwise, performs a REST call to get the data
return new Promise<IDropdownOption[]>((resolve,reject) => {
let serverUrl = Text.format("{0}//{1}", window.location.protocol, window.location.hostname);
this.searchService.getWebUrlsForDomain(serverUrl)
.then((urls:string[]) => {
let options:IDropdownOption[] = [ { key: "", text: strings.WebUrlFieldPlaceholder } ];
let urlOptions:IDropdownOption[] = urls.sort().map((url) => {
let serverRelativeUrl = !isEmpty(url.replace(serverUrl, '')) ? url.replace(serverUrl, '') : '/';
return { key: url, text: serverRelativeUrl };
});
options = options.concat(urlOptions);
this.webUrlOptions = options;
resolve(options);
})
.catch((error) => {
reject(error);
}
);
});
}
/**************************************************************************************************
* Gets the available lists from the specified web
* @param webUrl : The url of the web from which lists must be loaded from
**************************************************************************************************/
public getListTitleOptions(webUrl: string): Promise<IDropdownOption[]> {
Log.verbose(this.logSource, "Loading dropdown options for toolpart property 'List Title'...", this.context.serviceScope);
// Resolves an empty array if web is null
if (isEmpty(webUrl)) {
return Promise.resolve(new Array<IDropdownOption>());
}
// Resolves the already loaded data if available
if(this.listTitleOptions) {
return Promise.resolve(this.listTitleOptions);
}
// Otherwise gets the options asynchronously
return new Promise<IDropdownOption[]>((resolve, reject) => {
this.listService.getListTitlesFromWeb(webUrl).then((listTitles:string[]) => {
let options:IDropdownOption[] = [ { key: "", text: strings.ListTitleFieldPlaceholder } ];
let listTitleOptions = listTitles.map((listTitle) => { return { key: listTitle, text: listTitle }; });
options = options.concat(listTitleOptions);
this.listTitleOptions = options;
resolve(options);
})
.catch((error) => {
reject(this.getErrorMessage(webUrl, error));
});
});
}
/**************************************************************************************************
* Gets the available fields out of the specified web/list
* @param webUrl : The url of the web from which the list comes from
* @param listTitle : The title of the list from which the field must be loaded from
**************************************************************************************************/
public getOrderByOptions(webUrl: string, listTitle: string): Promise<IDropdownOption[]> {
Log.verbose(this.logSource, "Loading dropdown options for toolpart property 'Order By'...", this.context.serviceScope);
// Resolves an empty array if no web or no list has been selected
if (isEmpty(webUrl) || isEmpty(listTitle)) {
return Promise.resolve(new Array<IDropdownOption>());
}
// Resolves the already loaded data if available
if(this.orderByOptions) {
return Promise.resolve(this.orderByOptions);
}
// Otherwise gets the options asynchronously
return new Promise<IDropdownOption[]>((resolve, reject) => {
this.listService.getListFields(webUrl, listTitle, ['InternalName', 'Title', 'Sortable'], 'Title').then((data:any) => {
let sortableFields:any[] = data.value.filter((field) => { return field.Sortable == true; });
let options:IDropdownOption[] = [ { key: "", text: strings.queryFilterPanelStrings.queryFilterStrings.fieldSelectLabel } ];
let orderByOptions:IDropdownOption[] = sortableFields.map((field) => { return { key: field.InternalName, text: Text.format("{0} \{\{{1}\}\}", field.Title, field.InternalName) }; });
options = options.concat(orderByOptions);
this.orderByOptions = options;
resolve(options);
})
.catch((error) => {
reject(this.getErrorMessage(webUrl, error));
});
});
}
/***************************************************************************
* Gets the available fields out of the specified web/list
* @param webUrl : The url of the web from which the list comes from
* @param listTitle : The title of the list from which the field must be loaded from
***************************************************************************/
public getFilterFields(webUrl: string, listTitle: string):Promise<IQueryFilterField[]> {
Log.verbose(this.logSource, "Loading dropdown options for toolpart property 'Filters'...", this.context.serviceScope);
// Resolves an empty array if no web or no list has been selected
if (isEmpty(webUrl) || isEmpty(listTitle)) {
return Promise.resolve(new Array<IQueryFilterField>());
}
// Resolves the already loaded data if available
if(this.filterFields) {
return Promise.resolve(this.filterFields);
}
// Otherwise gets the options asynchronously
return new Promise<IQueryFilterField[]>((resolve, reject) => {
this.listService.getListFields(webUrl, listTitle, ['InternalName', 'Title', 'TypeAsString'], 'Title').then((data:any) => {
let fields:any[] = data.value;
let options:IQueryFilterField[] = fields.map((field) => { return {
internalName: field.InternalName,
displayName: field.Title,
type: this.getFieldTypeFromString(field.TypeAsString)
}; });
this.filterFields = options;
resolve(options);
})
.catch((error) => {
reject(this.getErrorMessage(webUrl, error));
});
});
}
/***************************************************************************
* Loads the checklist items for the viewFields property
***************************************************************************/
public getViewFieldsChecklistItems(webUrl: string, listTitle: string):Promise<IChecklistItem[]> {
Log.verbose(this.logSource, "Loading checklist items for toolpart property 'View Fields'...", this.context.serviceScope);
// Resolves an empty array if no web or no list has been selected
if (isEmpty(webUrl) || isEmpty(listTitle)) {
return Promise.resolve(new Array<IChecklistItem>());
}
// Resolves the already loaded data if available
if(this.viewFields) {
return Promise.resolve(this.viewFields);
}
// Otherwise gets the options asynchronously
return new Promise<IChecklistItem[]>((resolve, reject) => {
this.listService.getListFields(webUrl, listTitle, ['InternalName', 'Title'], 'Title').then((data:any) => {
let fields:any[] = data.value;
let items:IChecklistItem[] = fields.map((field) => { return {
id: field.InternalName,
label: Text.format("{0} \{\{{1}\}\}", field.Title, field.InternalName)
}; });
this.viewFields = items;
resolve(items);
})
.catch((error) => {
reject(this.getErrorMessage(webUrl, error));
});
});
}
/***************************************************************************
* Returns the user suggestions based on the user entered picker input
* @param webUrl : The web url on which to query for users
* @param filterText : The filter specified by the user in the people picker
* @param currentPersonas : The IPersonaProps already selected in the people picker
* @param limitResults : The results limit if any
***************************************************************************/
public getPeoplePickerSuggestions(webUrl: string, filterText: string, currentPersonas: IPersonaProps[], limitResults?: number):Promise<IPersonaProps[]> {
Log.verbose(this.logSource, "Getting people picker suggestions for toolpart property 'Filters'...", this.context.serviceScope);
return new Promise<IPersonaProps[]>((resolve, reject) => {
this.peoplePickerService.getUserSuggestions(webUrl, filterText, 1, 15, limitResults).then((data) => {
let users: any[] = JSON.parse(data.value);
let userSuggestions:IPersonaProps[] = users.map((user) => { return {
primaryText: user.DisplayText,
value: user.EntityData.SPUserID || user.EntityData.SPGroupID
}; });
resolve(this.removeUserSuggestionsDuplicates(userSuggestions, currentPersonas));
})
.catch((error) => {
reject(error);
});
});
}
/***************************************************************************
* Returns the taxonomy suggestions based on the user entered picker input
* @param webUrl : The web url on which to query for users
* @param filterText : The filter specified by the user in the people picker
* @param currentPersonas : The IPersonaProps already selected in the people picker
* @param limitResults : The results limit if any
***************************************************************************/
public getTaxonomyPickerSuggestions(webUrl: string, listTitle: string, field: IQueryFilterField, filterText: string, currentTerms: ITag[]):Promise<ITag[]> {
Log.verbose(this.logSource, "Getting taxonomy picker suggestions for toolpart property 'Filters'...", this.context.serviceScope);
return new Promise<ITag[]>((resolve, reject) => {
this.taxonomyService.getSiteTaxonomyTermsByTermSet(webUrl, listTitle, field.internalName, this.context.pageContext.web.language).then((data:any) => {
let termField = Text.format('Term{0}', this.context.pageContext.web.language);
let terms: any[] = data.value;
let termSuggestions: ITag[] = terms.map((term:any) => { return { key: term.Id, name: term[termField] }; });
resolve(this.removeTermSuggestionsDuplicates(termSuggestions, currentTerms));
})
.catch((error) => {
reject(error);
});
});
}
/*************************************************************************************************
* Performs a GET request against the specified file path and returns whether it resolved or not
* @param filePath : The path of the file that needs to be validated against a HEAD request
*************************************************************************************************/
public ensureFileResolves(filePath: string): Promise<{}> {
Log.verbose(this.logSource, Text.format("Checking if file exists at url '{0}'...", filePath), this.context.serviceScope);
return new Promise<boolean>((resolve, reject) => {
this.spHttpClient.get(filePath, SPHttpClient.configurations.v1).then((response: SPHttpClientResponse) => {
if(response.ok) {
resolve();
}
else {
reject(response.statusText);
}
})
.catch((error) => {
reject(error);
});
});
}
/*************************************************************************************************
* Returns whether the specified file path is a valid .htm or .html filePath
* @param filePath : The path of the file which needs to be validated
*************************************************************************************************/
public isValidTemplateFile(filePath: string): boolean {
Log.verbose(this.logSource, Text.format("Validating template file at url '{0}'...", filePath), this.context.serviceScope);
let path = filePath.toLowerCase().trim();
let pathExtension = path.substring(path.lastIndexOf('.'));
return (pathExtension == '.htm' || pathExtension == '.html');
}
/***************************************************************************
* Resets the stored 'list title' options
***************************************************************************/
public clearCachedListTitleOptions() {
Log.verbose(this.logSource, "Clearing cached dropdown options for toolpart property 'List Title'...", this.context.serviceScope);
this.listTitleOptions = null;
}
/***************************************************************************
* Resets the stored 'order by' options
***************************************************************************/
public clearCachedOrderByOptions() {
Log.verbose(this.logSource, "Clearing cached dropdown options for toolpart property 'Order By'...", this.context.serviceScope);
this.orderByOptions = null;
}
/***************************************************************************
* Resets the stored filter fields
***************************************************************************/
public clearCachedFilterFields() {
Log.verbose(this.logSource, "Clearing cached dropdown options for toolpart property 'Filter'...", this.context.serviceScope);
this.filterFields = null;
}
/***************************************************************************
* Resets the stored view fields
***************************************************************************/
public clearCachedViewFields() {
Log.verbose(this.logSource, "Clearing cached checklist items for toolpart property 'View Fields'...", this.context.serviceScope);
this.viewFields = null;
}
/***************************************************************************
* Normalizes the results coming from a CAML query into a userfriendly format for handlebars
* @param results : The results returned by a CAML query executed against a list
***************************************************************************/
private normalizeQueryResults(results: any[], viewFields: string[]): any[] {
Log.verbose(this.logSource, "Normalizing results for the requested handlebars context...", this.context.serviceScope);
let normalizedResults: any[] = [];
for(let result of results) {
let normalizedResult: any = {};
for(let viewField of viewFields) {
normalizedResult[viewField] = {
textValue: result.FieldValuesAsText[viewField],
htmlValue: result.FieldValuesAsHtml[viewField],
rawValue: result[viewField] || result[viewField + 'Id']
};
}
normalizedResults.push(normalizedResult);
}
return normalizedResults;
}
/***************************************************************************
* Returns an error message based on the specified error object
* @param error : An error string/object
***************************************************************************/
private getErrorMessage(webUrl: string, error: any): string {
let errorMessage:string = error.statusText ? error.statusText : error;
let serverUrl = Text.format("{0}//{1}", window.location.protocol, window.location.hostname);
let webServerRelativeUrl = webUrl.replace(serverUrl, '');
if(error.status === 403) {
errorMessage = Text.format(strings.ErrorWebAccessDenied, webServerRelativeUrl);
}
else if(error.status === 404) {
errorMessage = Text.format(strings.ErrorWebNotFound, webServerRelativeUrl);
}
return errorMessage;
}
/***************************************************************************
* Returns a field type enum value based on the provided string type
* @param fieldTypeStr : The field type as a string
***************************************************************************/
private getFieldTypeFromString(fieldTypeStr: string): QueryFilterFieldType {
let fieldType:QueryFilterFieldType;
switch(fieldTypeStr.toLowerCase().trim()) {
case 'user': fieldType = QueryFilterFieldType.User;
break;
case 'usermulti': fieldType = QueryFilterFieldType.User;
break;
case 'datetime': fieldType= QueryFilterFieldType.Datetime;
break;
case 'lookup': fieldType = QueryFilterFieldType.Lookup;
break;
case 'url': fieldType = QueryFilterFieldType.Url;
break;
case 'number': fieldType = QueryFilterFieldType.Number;
break;
case 'taxonomyfieldtype': fieldType = QueryFilterFieldType.Taxonomy;
break;
case 'taxonomyfieldtypemulti': fieldType = QueryFilterFieldType.Taxonomy;
break;
default: fieldType = QueryFilterFieldType.Text;
break;
}
return fieldType;
}
/***************************************************************************
* Returns the specified users with possible duplicates removed
* @param users : The user suggestions from which duplicates must be removed
* @param currentUsers : The current user suggestions that could be duplicates
***************************************************************************/
private removeUserSuggestionsDuplicates(users: IPersonaProps[], currentUsers: IPersonaProps[]): IPersonaProps[] {
Log.verbose(this.logSource, "Removing user suggestions duplicates for toolpart property 'Filters'...", this.context.serviceScope);
let trimmedUsers: IPersonaProps[] = [];
for(let user of users) {
let isDuplicate = currentUsers.filter((u) => { return u.value === user.value; }).length > 0;
if(!isDuplicate) {
trimmedUsers.push(user);
}
}
return trimmedUsers;
}
/***************************************************************************
* Returns the specified users with possible duplicates removed
* @param users : The user suggestions from which duplicates must be removed
* @param currentUsers : The current user suggestions that could be duplicates
***************************************************************************/
private removeTermSuggestionsDuplicates(terms: ITag[], currentTerms: ITag[]): ITag[] {
Log.verbose(this.logSource, "Removing term suggestions duplicates for toolpart property 'Filters'...", this.context.serviceScope);
let trimmedTerms: ITag[] = [];
for(let term of terms) {
let isDuplicate = currentTerms.filter((t) => { return t.key === term.key; }).length > 0;
if(!isDuplicate) {
trimmedTerms.push(term);
}
}
return trimmedTerms;
}
}

View File

@ -0,0 +1,24 @@
import { IDropdownOption, IPersonaProps, ITag } from 'office-ui-fabric-react';
import { IQueryFilterField } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/IQueryFilterField';
import { IChecklistItem } from '../../controls/PropertyPaneAsyncChecklist/components/AsyncChecklist/IChecklistItem';
import { IContentQueryTemplateContext } from '../../webparts/contentQuery/components/IContentQueryTemplateContext';
import { IQuerySettings } from '../../webparts/contentQuery/components/IQuerySettings';
export interface IContentQueryService {
getTemplateContext: (querySettings: IQuerySettings, callTimeStamp: number) => Promise<IContentQueryTemplateContext>;
getFileContent: (fileUrl: string) => Promise<string>;
getWebUrlOptions: () => Promise<IDropdownOption[]>;
getListTitleOptions: (webUrl: string) => Promise<IDropdownOption[]>;
getOrderByOptions: (webUrl: string, listTitle: string) => Promise<IDropdownOption[]>;
getFilterFields: (webUrl: string, listTitle: string) => Promise<IQueryFilterField[]>;
getViewFieldsChecklistItems: (webUrl: string, listTitle: string) => Promise<IChecklistItem[]>;
getPeoplePickerSuggestions: (webUrl: string, filterText: string, currentPersonas: IPersonaProps[], limitResults?: number) => Promise<IPersonaProps[]>;
getTaxonomyPickerSuggestions: (webUrl: string, listTitle: string, field: IQueryFilterField, filterText: string, currentTerms: ITag[]) => Promise<IPersonaProps[]>;
ensureFileResolves: (filePath: string) => Promise<{}>;
isValidTemplateFile: (filePath: string) => boolean;
clearCachedListTitleOptions: () => void;
clearCachedOrderByOptions: () => void;
clearCachedFilterFields: () => void;
clearCachedViewFields: () => void;
}

View File

@ -0,0 +1,112 @@
import { Text } from '@microsoft/sp-core-library';
import { SPHttpClient, ISPHttpClientOptions, SPHttpClientResponse } from '@microsoft/sp-http';
export class ListService {
/***************************************************************************
* The spHttpClient object used for performing REST calls to SharePoint
***************************************************************************/
private spHttpClient: SPHttpClient;
/**************************************************************************************************
* Constructor
* @param httpClient : The spHttpClient required to perform REST calls against SharePoint
**************************************************************************************************/
constructor(spHttpClient: SPHttpClient) {
this.spHttpClient = spHttpClient;
}
/**************************************************************************************************
* Performs a CAML query against the specified list and returns the resulting items
* @param webUrl : The url of the web which contains the specified list
* @param listTitle : The title of the list which contains the elements to query
* @param camlQuery : The CAML query to perform on the specified list
**************************************************************************************************/
public getListItemsByQuery(webUrl: string, listTitle: string, camlQuery: string): Promise<any> {
return new Promise<any>((resolve,reject) => {
let endpoint = Text.format("{0}/_api/web/lists/GetByTitle('{1}')/GetItems?$expand=FieldValuesAsText,FieldValuesAsHtml", webUrl, listTitle);
let data:any = {
query : {
__metadata: { type: "SP.CamlQuery" },
ViewXml: camlQuery
}
};
let options: ISPHttpClientOptions = { headers: { 'odata-version': '3.0' }, body: JSON.stringify(data) };
// Tests the web URL against 404 errors before executing the query, to avoid a bug that occurs with SPHttpClient.post when trying to post
// https://github.com/SharePoint/sp-dev-docs/issues/553
this.spHttpClient.get(webUrl, SPHttpClient.configurations.v1, { method: 'HEAD' })
.then((headResponse: SPHttpClientResponse) => {
if(headResponse.status != 404) {
// If there is no 404, proceeds with the CAML query
this.spHttpClient.post(endpoint, SPHttpClient.configurations.v1, options)
.then((postResponse: SPHttpClientResponse) => {
if(postResponse.ok) {
resolve(postResponse.json());
}
else {
reject(postResponse);
}
})
.catch((error) => { reject(error); });
}
else {
reject(headResponse);
}
})
.catch((error) => { reject(error); });
});
}
/**************************************************************************************************
* Returns a sorted array of all available list titles for the specified web
* @param webUrl : The web URL from which the list titles must be taken from
**************************************************************************************************/
public getListTitlesFromWeb(webUrl: string): Promise<string[]> {
return new Promise<string[]>((resolve,reject) => {
let endpoint = Text.format("{0}/_api/web/lists?$select=Title&$filter=(IsPrivate eq false) and (IsCatalog eq false) and (Hidden eq false)", webUrl);
this.spHttpClient.get(endpoint, SPHttpClient.configurations.v1).then((response: SPHttpClientResponse) => {
if(response.ok) {
response.json().then((data: any) => {
let listTitles:string[] = data.value.map((list) => { return list.Title; });
resolve(listTitles.sort());
})
.catch((error) => { reject(error); });
}
else {
reject(response);
}
})
.catch((error) => { reject(error); });
});
}
/**************************************************************************************************
* Returns a sorted array of all available list titles for the specified web
* @param webUrl : The web URL from which the specified list is located
* @param listTitle : The title of the list from which to load the fields
* @param selectProperties : Optionnaly, the select properties to narrow down the query scope
**************************************************************************************************/
public getListFields(webUrl: string, listTitle: string, selectProperties?: string[], orderBy?: string): Promise<any> {
return new Promise<any>((resolve,reject) => {
let selectProps = selectProperties ? selectProperties.join(',') : '';
let order = orderBy ? orderBy : 'InternalName';
let endpoint = Text.format("{0}/_api/web/lists/GetByTitle('{1}')/Fields?$select={2}&$orderby={3}", webUrl, listTitle, selectProps, order);
this.spHttpClient.get(endpoint, SPHttpClient.configurations.v1).then((response: SPHttpClientResponse) => {
if(response.ok) {
resolve(response.json());
}
else {
reject(response);
}
})
.catch((error) => { reject(error); });
});
}
}

View File

@ -0,0 +1,61 @@
import { Text } from '@microsoft/sp-core-library';
import { SPHttpClient, ISPHttpClientOptions, SPHttpClientResponse } from '@microsoft/sp-http';
export class PeoplePickerService {
/***************************************************************************
* The spHttpClient object used for performing REST calls to SharePoint
***************************************************************************/
private spHttpClient: SPHttpClient;
/**************************************************************************************************
* Constructor
* @param httpClient : The spHttpClient required to perform REST calls against SharePoint
**************************************************************************************************/
constructor(spHttpClient: SPHttpClient) {
this.spHttpClient = spHttpClient;
}
/**************************************************************************************************
* Performs a CAML query against the specified list and returns the resulting items
* @param webUrl : The url of the current web
* @param query : The query on which the user suggestions must be based on
* @param principalSource : The source to search (15=All, 4=Membership Provider, 8=Role Provider, 1=User Info List, 2=Windows)
* @param principalType : The type of entities returned (15=All, 2=Distribution Lists, 4=Security Groups,8=SharePoint Groups, 1=Users)
* @param maximumEntitySuggestion : Limit the amount of returned results
**************************************************************************************************/
public getUserSuggestions(webUrl: string, query: string, principalSource: number, principalType: number, maximumEntitySuggestion?: number): Promise<any> {
return new Promise<any>((resolve,reject) => {
let endpoint = Text.format("{0}/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser", webUrl);
let data:any = {
queryParams:{
__metadata:{
'type':'SP.UI.ApplicationPages.ClientPeoplePickerQueryParameters'
},
QueryString: query,
PrincipalSource: principalSource,
PrincipalType: principalType,
MaximumEntitySuggestions: maximumEntitySuggestion || 50
}
};
let options: ISPHttpClientOptions = { headers: { 'odata-version': '3.0' }, body: JSON.stringify(data) };
this.spHttpClient.post(endpoint, SPHttpClient.configurations.v1, options)
.then((response: SPHttpClientResponse) => {
if(response.ok) {
resolve(response.json());
}
else {
reject(response.statusText);
}
})
.catch((error) => {
reject(error);
}
);
});
}
}

View File

@ -0,0 +1,61 @@
import { Text } from '@microsoft/sp-core-library';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
export class SearchService {
/***************************************************************************
* The spHttpClient object used for performing REST calls to SharePoint
***************************************************************************/
private spHttpClient: SPHttpClient;
/**************************************************************************************************
* Constructor
* @param httpClient : The spHttpClient required to perform REST calls against SharePoint
**************************************************************************************************/
constructor(spHttpClient: SPHttpClient) {
this.spHttpClient = spHttpClient;
}
/**************************************************************************************************
* Returns the web urls starting with the specified domain to which the current user has access
* @param domainUrl : The url of the web which contains the specified list
**************************************************************************************************/
public getWebUrlsForDomain(domainUrl: string): Promise<string[]> {
return new Promise<string[]>((resolve,reject) => {
let endpoint = Text.format("{0}/_api/search/query?querytext='Path:{0}/* AND (contentclass:STS_Site OR contentclass:STS_Web)'&selectproperties='Path'&trimduplicates=false", domainUrl);
// Gets the available webs for the current domain with a search query
this.spHttpClient.get(endpoint, SPHttpClient.configurations.v1).then((response: SPHttpClientResponse) => {
if(response.ok) {
response.json().then((data:any) => {
try {
let urls:string[] = [];
let pathIndex = null;
for(let result of data.PrimaryQueryResult.RelevantResults.Table.Rows) {
// Stores the index of the "Path" cell on the first loop in order to avoid finding the cell on every loop
if(!pathIndex) {
let pathCell = result.Cells.filter((cell) => { return cell.Key == "Path"; })[0];
pathIndex = result.Cells.indexOf(pathCell);
}
urls.push(result.Cells[pathIndex].Value);
}
resolve(urls);
}
catch(error) {
reject(error);
}
})
.catch((error) => { reject(error); });
}
else {
reject(response.statusText);
}
})
.catch((error) => { reject(error); });
});
}
}

View File

@ -0,0 +1,122 @@
import { Text } from '@microsoft/sp-core-library';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { SPComponentLoader } from '@microsoft/sp-loader';
import { isEmpty } from '@microsoft/sp-lodash-subset';
export class TaxonomyService {
/***************************************************************************
* The spHttpClient object used for performing REST calls to SharePoint
***************************************************************************/
private spHttpClient: SPHttpClient;
/**************************************************************************************************
* Constructor
* @param httpClient : The spHttpClient required to perform REST calls against SharePoint
**************************************************************************************************/
constructor(spHttpClient: SPHttpClient) {
this.spHttpClient = spHttpClient;
}
/**************************************************************************************************
* Gets the taxonomy terms associated with the specified taxonomy field's termset
* @param webUrl : The url of the web which contains the specified list
* @param listTitle : The title of the list which contains the specified taxonomy field
* @param fieldInternalName : The internal name of the taxonomy field on which to extract the termset
**************************************************************************************************/
public getSiteTaxonomyTermsByTermSet(webUrl: string, listTitle: string, fieldInternalName: string, lcid?: number): Promise<any> {
return new Promise<any>((resolve,reject) => {
// Gets the termset ID associated with the list field
this.getListFieldTermSetId(webUrl, listTitle, fieldInternalName).then((termsetId: string) => {
// Queries the Taxonomy Hidden list to retreive all terms with their wssIds
let endpoint = Text.format("{0}/_api/web/lists/GetByTitle('TaxonomyHiddenList')/Items?$select=Term{1},ID&$filter=IdForTermSet eq '{2}'", webUrl, (lcid ? lcid : 1033), termsetId);
this.spHttpClient.get(endpoint, SPHttpClient.configurations.v1).then((response: SPHttpClientResponse) => {
if(response.ok) {
resolve(response.json());
}
else {
reject(response);
}
})
.catch((error) => { reject(error); });
})
.catch((error) => { reject(error); });
});
}
/**************************************************************************************************
* Gets the termset id out of the specified taxonomy field
* @param webUrl : The url of the web which contains the specified list
* @param listTitle : The title of the list which contains the sepcified field
* @param fieldInternalName : The internal name of the field on which to extract its termset id
**************************************************************************************************/
public getListFieldTermSetId(webUrl: string, listTitle: string, fieldInternalName: string): Promise<string> {
return new Promise<string>((resolve,reject) => {
let endpoint = Text.format("{0}/_api/web/lists/GetByTitle('{1}')/Fields?$select=IsTermSetValid,TermSetId&$filter=InternalName eq '{2}'", webUrl, listTitle, fieldInternalName);
this.spHttpClient.get(endpoint, SPHttpClient.configurations.v1).then((response: SPHttpClientResponse) => {
if(response.ok) {
response.json().then((data:any) => {
let fields:any[] = data.value;
let fieldTermSetId = null;
if(fields.length > 0) {
let field = fields[0];
if(field.IsTermSetValid && !isEmpty(field.TermSetId)) {
fieldTermSetId = field.TermSetId;
}
}
resolve(fieldTermSetId);
})
.catch((error) => { reject(error); });
}
else {
reject(response);
}
})
.catch((error) => { reject(error); });
});
}
/*************************************************************************************
* Ensures SP.js and its dependencies in order to be able to do JSOM later on
*************************************************************************************/
private ensureJSOMDependencies(): Promise<{}> {
if(window['SP']) {
return Promise.resolve();
}
else {
return SPComponentLoader.loadScript('/_layouts/15/init.js', {
globalExportsName: '$_global_init'
})
.then((): Promise<{}> => {
return SPComponentLoader.loadScript('/_layouts/15/MicrosoftAjax.js', {
globalExportsName: 'Sys'
});
})
.then((): Promise<{}> => {
return SPComponentLoader.loadScript('/_layouts/15/SP.Runtime.js', {
globalExportsName: 'SP'
});
})
.then((): Promise<{}> => {
return SPComponentLoader.loadScript('/_layouts/15/SP.js', {
globalExportsName: 'SP'
});
})
.then((): Promise<{}> => {
return SPComponentLoader.loadScript('/_layouts/15/SP.Taxonomy.js', {
globalExportsName: 'SP.Taxonomy'
});
});
}
}
}

View File

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

View File

@ -0,0 +1,10 @@
import { IChecklistItem } from './components/AsyncChecklist/IChecklistItem';
import { IAsyncChecklistStrings } from './components/AsyncChecklist/IAsyncChecklistStrings';
export interface IPropertyPaneAsyncChecklistProps {
loadItems: () => Promise<IChecklistItem[]>;
onPropertyChange: (propertyPath: string, newCheckedKeys: string[]) => void;
checkedItems: string[];
disable?: boolean;
strings: IAsyncChecklistStrings;
}

View File

@ -0,0 +1,78 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { IPropertyPaneField, PropertyPaneFieldType } from '@microsoft/sp-webpart-base';
import { IPropertyPaneAsyncChecklistProps } from './IPropertyPaneAsyncChecklistProps';
import { IPropertyPaneAsyncChecklistInternalProps } from './IPropertyPaneAsyncChecklistInternalProps';
import { AsyncChecklist } from './components/AsyncChecklist/AsyncChecklist';
import { IAsyncChecklistProps } from './components/AsyncChecklist/IAsyncChecklistProps';
export class PropertyPaneAsyncChecklist implements IPropertyPaneField<IPropertyPaneAsyncChecklistProps> {
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
public targetProperty: string;
public properties: IPropertyPaneAsyncChecklistInternalProps;
public loadedItems: boolean;
private elem: HTMLElement;
/*****************************************************************************************
* Property pane's contructor
* @param targetProperty
* @param properties
*****************************************************************************************/
constructor(targetProperty: string, properties: IPropertyPaneAsyncChecklistProps) {
this.targetProperty = targetProperty;
this.properties = {
loadItems: properties.loadItems,
checkedItems: properties.checkedItems,
onPropertyChange: properties.onPropertyChange,
disable: properties.disable,
strings: properties.strings,
onRender: this.onRender.bind(this),
key: targetProperty
};
}
/*****************************************************************************************
* Renders the AsyncChecklist property pane
*****************************************************************************************/
public render(): void {
if (!this.elem) {
return;
}
this.onRender(this.elem);
}
/*****************************************************************************************
* Renders the AsyncChecklist property pane
*****************************************************************************************/
private onRender(elem: HTMLElement): void {
if (!this.elem) {
this.elem = elem;
}
const asyncChecklist: React.ReactElement<IAsyncChecklistProps> = React.createElement(AsyncChecklist, {
loadItems: this.properties.loadItems,
checkedItems: this.properties.checkedItems,
onChange: this.onChange.bind(this),
disable: this.properties.disable,
strings: this.properties.strings,
stateKey: new Date().toString()
});
ReactDom.render(asyncChecklist, elem);
this.loadedItems = true;
}
/*****************************************************************************************
* Call the property pane's onPropertyChange when the QueryFilterPanel changes
*****************************************************************************************/
private onChange(checkedKeys: string[]): void {
this.properties.onPropertyChange(this.targetProperty, checkedKeys);
}
}

View File

@ -0,0 +1,27 @@
$lightgray: #f5f5f5;
.checklist {
.checklistItems {
background: $lightgray;
padding: 12px;
.checklistPadding {
overflow-x: hidden;
overflow-y: auto;
max-height: 280px;
.checklistItem {
min-height: initial;
margin-top: 9px;
&:first-child {
margin-top: 0px;
label {
margin-top: 0px;
}
}
}
}
}
}

View File

@ -0,0 +1,158 @@
import * as React from 'react';
import { clone } from '@microsoft/sp-lodash-subset';
import { Text } from '@microsoft/sp-core-library';
import { Spinner, Label, Checkbox } from 'office-ui-fabric-react';
import { IAsyncChecklistProps } from './IAsyncChecklistProps';
import { IAsyncChecklistState } from './IAsyncChecklistState';
import { IChecklistItem } from './IChecklistItem';
import styles from './AsyncChecklist.module.scss';
export class AsyncChecklist extends React.Component<IAsyncChecklistProps, IAsyncChecklistState> {
/*************************************************************************************
* Stores the checked items
*************************************************************************************/
private checkedItems: string[];
/*************************************************************************************
* Component's constructor
*************************************************************************************/
constructor(props: IAsyncChecklistProps, state: IAsyncChecklistState) {
super(props);
this.state = { loading: true, items: [], error: null };
this.checkedItems = this.getDefaultCheckedItems();
}
/*************************************************************************************
* Gets the default checked items
*************************************************************************************/
private getDefaultCheckedItems() {
return this.props.checkedItems ? clone(this.props.checkedItems) : new Array<string>();
}
/*************************************************************************************
* When a checkbox changes within the checklist
* @param ev : The React.FormEvent object which contains the element that has changed
* @param checked : Whether the checkbox is not checked or not
*************************************************************************************/
private onCheckboxChange(ev?: React.FormEvent<HTMLInputElement>, checked?: boolean) {
let checkboxKey = ev.currentTarget.attributes.getNamedItem('data').value;
let itemIndex = this.checkedItems.indexOf(checkboxKey);
if(checked) {
if(itemIndex == -1) {
this.checkedItems.push(checkboxKey);
}
}
else {
if(itemIndex >= 0) {
this.checkedItems.splice(itemIndex, 1);
}
}
if(this.props.onChange) {
this.props.onChange(this.checkedItems);
}
}
/*************************************************************************************
* Returns whether the checkbox with the specified ID should be checked or not
* @param checkboxId
*************************************************************************************/
private isCheckboxChecked(checkboxId: string) {
return (this.checkedItems.filter((checkedItem) => { return checkedItem.toLowerCase().trim() == checkboxId.toLowerCase().trim(); }).length > 0);
}
/*************************************************************************************
* Loads the checklist items asynchronously
*************************************************************************************/
private loadItems() {
let _this_ = this;
_this_.checkedItems = this.getDefaultCheckedItems();
this.setState({
loading: true,
items: new Array<IChecklistItem>(),
error: null
});
this.props.loadItems().then((items: IChecklistItem[]) => {
_this_.setState((prevState: IAsyncChecklistState, props: IAsyncChecklistProps): IAsyncChecklistState => {
prevState.loading = false;
prevState.items = items;
return prevState;
});
})
.catch((error: any) => {
_this_.setState((prevState: IAsyncChecklistState, props: IAsyncChecklistProps): IAsyncChecklistState => {
prevState.loading = false;
prevState.error = error;
return prevState;
});
});
}
/*************************************************************************************
* Called once after initial rendering
*************************************************************************************/
public componentDidMount(): void {
this.loadItems();
}
/*************************************************************************************
* Called immediately after updating occurs
*************************************************************************************/
public componentDidUpdate(prevProps: IAsyncChecklistProps, prevState: {}): void {
if (this.props.disable !== prevProps.disable || this.props.stateKey !== prevProps.stateKey) {
this.loadItems();
}
}
/*************************************************************************************
* Renders the the QueryFilter component
*************************************************************************************/
public render() {
const loading = this.state.loading ? <Spinner label={this.props.strings.loading} /> : <div />;
const error = this.state.error != null ? <div className="ms-TextField-errorMessage ms-u-slideDownIn20">{ Text.format(this.props.strings.errorFormat, this.state.error) }</div> : <div />;
const checklistItems = this.state.items.map((item, index) => {
return (
<Checkbox data={ item.id }
label={ item.label }
defaultChecked={ this.isCheckboxChecked(item.id) }
disabled={ this.props.disable }
onChange={ this.onCheckboxChange.bind(this) }
inputProps={ { data: item.id } }
className={ styles.checklistItem }
key={ index } />
);
});
return (
<div className={ styles.checklist }>
<Label>{ this.props.strings.label }</Label>
{ loading }
{ !this.state.loading &&
<div className={ styles.checklistItems }>
<div className={ styles.checklistPadding }>{ checklistItems }</div>
</div>
}
{ error }
</div>
);
}
}

View File

@ -0,0 +1,11 @@
import { IChecklistItem } from './IChecklistItem';
import { IAsyncChecklistStrings } from './IAsyncChecklistStrings';
export interface IAsyncChecklistProps {
loadItems: () => Promise<IChecklistItem[]>;
onChange?: (checkedKeys:string[]) => void;
checkedItems: string[];
disable?: boolean;
strings: IAsyncChecklistStrings;
stateKey?: string;
}

View File

@ -0,0 +1,7 @@
import { IChecklistItem } from './IChecklistItem';
export interface IAsyncChecklistState {
loading: boolean;
items: IChecklistItem[];
error: string;
}

View File

@ -0,0 +1,5 @@
export interface IAsyncChecklistStrings {
label: string;
loading: string;
errorFormat: string;
}

View File

@ -0,0 +1,4 @@
export interface IChecklistItem {
id: string;
label: string;
}

View File

@ -0,0 +1,5 @@
import { IPropertyPaneCustomFieldProps } from '@microsoft/sp-webpart-base';
import { IPropertyPaneAsyncDropdownProps } from './IPropertyPaneAsyncDropdownProps';
export interface IPropertyPaneAsyncDropdownInternalProps extends IPropertyPaneAsyncDropdownProps, IPropertyPaneCustomFieldProps {
}

View File

@ -0,0 +1,11 @@
import { IDropdownOption } from 'office-ui-fabric-react';
export interface IPropertyPaneAsyncDropdownProps {
label: string;
loadingLabel: string;
errorLabelFormat: string;
loadOptions: () => Promise<IDropdownOption[]>;
onPropertyChange: (propertyPath: string, newValue: any) => void;
selectedKey?: string | number;
disabled?: boolean;
}

View File

@ -0,0 +1,81 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { IPropertyPaneField, PropertyPaneFieldType } from '@microsoft/sp-webpart-base';
import { IDropdownOption } from 'office-ui-fabric-react';
import { IPropertyPaneAsyncDropdownProps } from './IPropertyPaneAsyncDropdownProps';
import { IPropertyPaneAsyncDropdownInternalProps } from './IPropertyPaneAsyncDropdownInternalProps';
import { AsyncDropdown } from './components/AsyncDropdown/AsyncDropdown';
import { IAsyncDropdownProps } from './components/AsyncDropdown/IAsyncDropdownProps';
export class PropertyPaneAsyncDropdown implements IPropertyPaneField<IPropertyPaneAsyncDropdownProps> {
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
public targetProperty: string;
public properties: IPropertyPaneAsyncDropdownInternalProps;
private elem: HTMLElement;
/*****************************************************************************************
* Property pane's contructor
* @param targetProperty
* @param properties
*****************************************************************************************/
constructor(targetProperty: string, properties: IPropertyPaneAsyncDropdownProps) {
this.targetProperty = targetProperty;
this.properties = {
label: properties.label,
loadingLabel: properties.loadingLabel,
errorLabelFormat: properties.errorLabelFormat,
loadOptions: properties.loadOptions,
onPropertyChange: properties.onPropertyChange,
selectedKey: properties.selectedKey,
disabled: properties.disabled,
onRender: this.onRender.bind(this),
key: targetProperty
};
}
/*****************************************************************************************
* Renders the AsyncDropdown property pane
*****************************************************************************************/
public render(): void {
if (!this.elem) {
return;
}
this.onRender(this.elem);
}
/*****************************************************************************************
* Renders the AsyncDropdown property pane
*****************************************************************************************/
private onRender(elem: HTMLElement): void {
if (!this.elem) {
this.elem = elem;
}
const asyncDropDown: React.ReactElement<IAsyncDropdownProps> = React.createElement(AsyncDropdown, {
label: this.properties.label,
loadingLabel: this.properties.loadingLabel,
errorLabelFormat: this.properties.errorLabelFormat,
loadOptions: this.properties.loadOptions,
onChanged: this.onChanged.bind(this),
selectedKey: this.properties.selectedKey,
disabled: this.properties.disabled,
// required to allow the component to be re-rendered by calling this.render() externally
stateKey: new Date().toString()
});
ReactDom.render(asyncDropDown, elem);
}
/*****************************************************************************************
* Call the property pane's onPropertyChange when the AsyncDropdown changes
*****************************************************************************************/
private onChanged(option: IDropdownOption, index?: number): void {
this.properties.onPropertyChange(this.targetProperty, option.key);
}
}

View File

@ -0,0 +1,91 @@
import * as React from 'react';
import { Text } from '@microsoft/sp-core-library';
import { Dropdown, IDropdownOption, Spinner } from 'office-ui-fabric-react';
import { IAsyncDropdownProps } from './IAsyncDropdownProps';
import { IAsyncDropdownState } from './IAsyncDropdownState';
export class AsyncDropdown extends React.Component<IAsyncDropdownProps, IAsyncDropdownState> {
/*************************************************************************************
* Component's constructor
* @param props
* @param state
*************************************************************************************/
constructor(props: IAsyncDropdownProps, state: IAsyncDropdownState) {
super(props);
this.state = {
processed: false,
options: new Array<IDropdownOption>(),
error: null
};
}
/*************************************************************************************
* Called once after initial rendering
*************************************************************************************/
public componentDidMount(): void {
this.loadOptions();
}
/*************************************************************************************
* Called immediately after updating occurs
*************************************************************************************/
public componentDidUpdate(prevProps: IAsyncDropdownProps, prevState: IAsyncDropdownState): void {
if (this.props.disabled !== prevProps.disabled || this.props.stateKey !== prevProps.stateKey) {
this.loadOptions();
}
}
/*************************************************************************************
* Loads the dropdown options asynchronously
*************************************************************************************/
private loadOptions(): void {
this.setState({
processed: false,
error: null,
options: new Array<IDropdownOption>()
});
this.props.loadOptions().then((options: IDropdownOption[]) => {
this.setState({
processed: true,
error: null,
options: options
});
})
.catch((error: any) => {
this.setState((prevState: IAsyncDropdownState, props: IAsyncDropdownProps): IAsyncDropdownState => {
prevState.processed = true;
prevState.error = error;
return prevState;
});
});
}
/*************************************************************************************
* Renders the the AsyncDropdown component
*************************************************************************************/
public render() {
const loading = !this.state.processed ? <Spinner label={this.props.loadingLabel} /> : <div />;
const error = this.state.error != null ? <div className="ms-TextField-errorMessage ms-u-slideDownIn20">{ Text.format(this.props.errorLabelFormat, this.state.error) }</div> : <div />;
return (
<div>
<Dropdown label={this.props.label}
isDisabled={this.props.disabled}
onChanged={this.props.onChanged}
selectedKey={this.props.selectedKey}
options={this.state.options} />
{loading}
{error}
</div>
);
}
}

View File

@ -0,0 +1,12 @@
import { IDropdownOption } from 'office-ui-fabric-react';
export interface IAsyncDropdownProps {
label: string;
loadingLabel: string;
errorLabelFormat: string;
loadOptions: () => Promise<IDropdownOption[]>;
onChanged?: (option: IDropdownOption, index?: number) => void;
selectedKey?: string | number;
disabled?: boolean;
stateKey?: string;
}

View File

@ -0,0 +1,7 @@
import { IDropdownOption } from 'office-ui-fabric-react';
export interface IAsyncDropdownState {
processed: boolean;
options: IDropdownOption[];
error: string;
}

View File

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

View File

@ -0,0 +1,15 @@
import { IPersonaProps, ITag } from 'office-ui-fabric-react';
import { IQueryFilter } from './components/QueryFilter/IQueryFilter';
import { IQueryFilterField } from './components/QueryFilter/IQueryFilterField';
import { IQueryFilterPanelStrings } from './components/QueryFilterPanel/IQueryFilterPanelStrings';
export interface IPropertyPaneQueryFilterPanelProps {
filters: IQueryFilter[];
loadFields: () => Promise<IQueryFilterField[]>;
onLoadTaxonomyPickerSuggestions: (field: IQueryFilterField, filterText: string, currentTerms: ITag[]) => Promise<ITag[]>;
onLoadPeoplePickerSuggestions: (filterText: string, currentPersonas: IPersonaProps[], limitResults?: number) => Promise<IPersonaProps[]>;
onPropertyChange: (propertyPath: string, newFilters: IQueryFilter[]) => void;
trimEmptyFiltersOnChange?: boolean;
disabled?: boolean;
strings: IQueryFilterPanelStrings;
}

View File

@ -0,0 +1,83 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { IPropertyPaneField, PropertyPaneFieldType } from '@microsoft/sp-webpart-base';
import { IPropertyPaneQueryFilterPanelProps } from './IPropertyPaneQueryFilterPanelProps';
import { IPropertyPaneQueryFilterPanelInternalProps } from './IPropertyPaneQueryFilterPanelInternalProps';
import { IQueryFilter } from './components/QueryFilter/IQueryFilter';
import { QueryFilterPanel } from './components/QueryFilterPanel/QueryFilterPanel';
import { IQueryFilterPanelProps } from './components/QueryFilterPanel/IQueryFilterPanelProps';
export class PropertyPaneQueryFilterPanel implements IPropertyPaneField<IPropertyPaneQueryFilterPanelProps> {
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
public targetProperty: string;
public properties: IPropertyPaneQueryFilterPanelInternalProps;
private elem: HTMLElement;
/*****************************************************************************************
* Property pane's contructor
* @param targetProperty
* @param properties
*****************************************************************************************/
constructor(targetProperty: string, properties: IPropertyPaneQueryFilterPanelProps) {
this.targetProperty = targetProperty;
this.properties = {
filters: properties.filters,
loadFields: properties.loadFields,
onLoadTaxonomyPickerSuggestions: properties.onLoadTaxonomyPickerSuggestions,
onLoadPeoplePickerSuggestions: properties.onLoadPeoplePickerSuggestions,
onPropertyChange: properties.onPropertyChange,
trimEmptyFiltersOnChange: properties.trimEmptyFiltersOnChange,
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 queryFilterpanel: React.ReactElement<IQueryFilterPanelProps> = React.createElement(QueryFilterPanel, {
filters: this.properties.filters,
loadFields: this.properties.loadFields,
onLoadTaxonomyPickerSuggestions: this.properties.onLoadTaxonomyPickerSuggestions,
onLoadPeoplePickerSuggestions: this.properties.onLoadPeoplePickerSuggestions,
onChanged: this.onChanged.bind(this),
trimEmptyFiltersOnChange: this.properties.trimEmptyFiltersOnChange,
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(queryFilterpanel, elem);
}
/*****************************************************************************************
* Call the property pane's onPropertyChange when the QueryFilterPanel changes
*****************************************************************************************/
private onChanged(filters: IQueryFilter[]): void {
this.properties.onPropertyChange(this.targetProperty, filters);
}
}

View File

@ -0,0 +1,14 @@
import { IQueryFilterField } from './IQueryFilterField';
import { QueryFilterOperator } from './QueryFilterOperator';
import { QueryFilterJoin } from './QueryFilterJoin';
import { IPersonaProps, ITag } from 'office-ui-fabric-react';
export interface IQueryFilter {
field: IQueryFilterField;
operator: QueryFilterOperator;
value: string | IPersonaProps[] | ITag[] | Date;
expression?: string;
includeTime?: boolean;
me?: boolean;
join: QueryFilterJoin;
}

View File

@ -0,0 +1,7 @@
import { QueryFilterFieldType } from './QueryFilterFieldType';
export interface IQueryFilterField {
internalName: string;
displayName: string;
type: QueryFilterFieldType;
}

View File

@ -0,0 +1,16 @@
import { IPersonaProps, ITag } from 'office-ui-fabric-react';
import { IQueryFilter } from './IQueryFilter';
import { IQueryFilterField } from './IQueryFilterField';
import { IQueryFilterStrings } from './IQueryFilterStrings';
export interface IQueryFilterProps {
filter: IQueryFilter;
fields: IQueryFilterField[];
onLoadTaxonomyPickerSuggestions: (field: IQueryFilterField, filterText: string, currentTerms: ITag[]) => Promise<ITag[]>;
onLoadPeoplePickerSuggestions: (filterText: string, currentPersonas: IPersonaProps[], limitResults?: number) => Promise<IPersonaProps[]>;
onChanged?: (filter: IQueryFilter, index: number) => void;
disabled?: boolean;
strings: IQueryFilterStrings;
index?: number;
}

View File

@ -0,0 +1,6 @@
import { IQueryFilter } from './IQueryFilter';
export interface IQueryFilterState {
filter: IQueryFilter;
pickersKey: number;
}

View File

@ -0,0 +1,36 @@
import { IDatePickerStrings } from 'office-ui-fabric-react';
export interface IQueryFilterStrings {
fieldLabel: string;
fieldSelectLabel: string;
operatorLabel: string;
operatorEqualLabel: string;
operatorNotEqualLabel: string;
operatorGreaterLabel: string;
operatorGreaterEqualLabel: string;
operatorLessLabel: string;
operatorLessEqualLabel: string;
operatorContainsLabel: string;
operatorBeginsWithLabel: string;
operatorContainsAnyLabel: string;
operatorContainsAllLabel: string;
operatorIsNullLabel: string;
operatorIsNotNullLabel: string;
valueLabel: string;
andLabel: string;
orLabel: string;
peoplePickerSuggestionHeader: string;
peoplePickerNoResults: string;
peoplePickerLoading: string;
peoplePickerMe: string;
taxonomyPickerSuggestionHeader: string;
taxonomyPickerNoResults: string;
taxonomyPickerLoading: string;
datePickerStrings: IDatePickerStrings;
datePickerLocale: string;
datePickerFormat: string;
datePickerExpressionError: string;
datePickerDatePlaceholder: string;
datePickerExpressionPlaceholder: string;
datePickerIncludeTime: string;
}

View File

@ -0,0 +1,38 @@
$lightgray: #f5f5f5;
.queryFilter {
background-color: $lightgray;
&.disabled {
background-color: transparent;
border: 1px solid $lightgray;
}
.paddingContainer {
padding: 12px 15px 3px 15px;
div[class~=ms-ChoiceFieldGroup] {
text-align: center;
div[class~=ms-ChoiceField] {
display: inline-block;
}
}
div[class~="ms-BasePicker-text"] {
background: #fff;
}
.peoplePicker {
&.disabled {
div[class~="ms-PickerPersona-container"] {
display: none;
}
}
}
span[class~="ms-TagItem-text"] {
max-width: 201px;
}
}
}

View File

@ -0,0 +1,419 @@
import * as React from 'react';
import * as moment from 'moment';
import { cloneDeep, isEmpty } from '@microsoft/sp-lodash-subset';
import { Text } from '@microsoft/sp-core-library';
import { Dropdown, IDropdownOption, TextField, ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react';
import { NormalPeoplePicker, IPersonaProps, IBasePickerSuggestionsProps, Label } from 'office-ui-fabric-react';
import { TagPicker, ITag } from 'office-ui-fabric-react';
import { DatePicker, Checkbox } from 'office-ui-fabric-react';
import { IQueryFilter } from './IQueryFilter';
import { QueryFilterOperator } from './QueryFilterOperator';
import { QueryFilterJoin } from './QueryFilterJoin';
import { QueryFilterFieldType } from './QueryFilterFieldType';
import { IQueryFilterProps } from './IQueryFilterProps';
import { IQueryFilterState } from './IQueryFilterState';
import styles from './QueryFilter.module.scss';
export class QueryFilter extends React.Component<IQueryFilterProps, IQueryFilterState> {
/*************************************************************************************
* Stores the IQueryFilter config of the current filter
*************************************************************************************/
private filter:IQueryFilter;
/*************************************************************************************
* Component's constructor
* @param props
* @param state
*************************************************************************************/
constructor(props: IQueryFilterProps, state: IQueryFilterState) {
super(props);
moment.locale(this.props.strings.datePickerLocale);
this.state = {
filter: (this.props.filter ? cloneDeep(this.props.filter) : { field: null, operator: QueryFilterOperator.Eq, value: '', join: QueryFilterJoin.Or }),
pickersKey: Math.random()
};
this.onAnyChange = this.onAnyChange.bind(this);
}
/*************************************************************************************
* When the field Dropdown changes
*************************************************************************************/
private onFieldDropdownChange(option: IDropdownOption, index?: number) {
let field = this.props.fields.filter((f) => { return f.internalName == option.key; });
this.state.filter.field = field != null && field.length > 0 ? field[0] : null;
this.state.filter.operator = (this.state.filter.field && (this.state.filter.field.type == QueryFilterFieldType.User || this.state.filter.field.type == QueryFilterFieldType.Taxonomy) ? QueryFilterOperator.ContainsAny : QueryFilterOperator.Eq);
this.state.filter.value = null;
this.state.filter.me = false;
this.state.filter.includeTime = false;
this.state.filter.expression = null;
this.setState({ filter: this.state.filter, pickersKey: Math.random() });
this.onAnyChange();
}
/*************************************************************************************
* When the operator Dropdown changes
*************************************************************************************/
private onOperatorDropdownChange(option: IDropdownOption, index?: number) {
this.state.filter.operator = QueryFilterOperator[option.key];
this.setState({ filter: this.state.filter, pickersKey: this.state.pickersKey });
this.onAnyChange();
}
/*************************************************************************************
* When the TextField value changes
*************************************************************************************/
private onValueTextFieldChange(newValue: string): string {
this.state.filter.value = newValue;
this.onAnyChange();
return '';
}
/*************************************************************************************
* When the people picker value changes
*************************************************************************************/
private onPeoplePickerResolve(items: IPersonaProps[]) {
this.state.filter.value = items;
this.onAnyChange();
}
/*************************************************************************************
* When the "Me" checkbox changes
* @param ev : The React.FormEvent object which contains the element that has changed
* @param checked : Whether the checkbox is not checked or not
*************************************************************************************/
private onPeoplePickerCheckboxChange(ev?: React.FormEvent<HTMLInputElement>, checked?: boolean) {
this.state.filter.me = checked;
this.setState({ filter: this.state.filter, pickersKey: this.state.pickersKey });
this.onAnyChange();
}
/*************************************************************************************
* When the NormalPeoplePicker value changes
*************************************************************************************/
private onTaxonomyPickerResolve(items: ITag[]) {
this.state.filter.value = items;
this.onAnyChange();
}
/*************************************************************************************
* When the date picker value changes
*************************************************************************************/
private onDatePickerChange(date: Date) {
this.state.filter.value = date;
this.state.filter.expression = '';
this.setState({ filter: this.state.filter, pickersKey: this.state.pickersKey });
this.onAnyChange();
}
/*************************************************************************************
* When the date expression text field value changes
*************************************************************************************/
private onDateExpressionChange(newValue: string): string {
// Validates the picker
let regex = new RegExp(/^\[Today\](\s{0,}[\+-]\s{0,}\[{0,1}\d{1,4}\]{0,1}){0,1}$/);
let isValid = regex.test(newValue) || isEmpty(newValue);
let errorMsg = isValid ? '' : this.props.strings.datePickerExpressionError;
if(isValid) {
// If the change is NOT triggered by the date picker change
if(!(isEmpty(newValue) && this.state.filter.value != null)) {
this.state.filter.value = null;
this.state.filter.expression = newValue;
this.setState({ filter: this.state.filter, pickersKey: this.state.pickersKey });
this.onAnyChange();
}
}
return errorMsg;
}
/*************************************************************************************
* When the include time checkbox changes
* @param ev : The React.FormEvent object which contains the element that has changed
* @param checked : Whether the checkbox is not checked or not
*************************************************************************************/
private onDateIncludeTimeChange(ev?: React.FormEvent<HTMLInputElement>, checked?: boolean) {
this.state.filter.includeTime = checked;
this.onAnyChange();
}
/*************************************************************************************
* When the join ChoiceGroup changes
*************************************************************************************/
private onJoinChoiceChange(ev?: React.FormEvent<HTMLInputElement>, option?: IChoiceGroupOption) {
if(option) {
this.state.filter.join = QueryFilterJoin[option.key];
this.onAnyChange();
}
}
/*************************************************************************************
* Call the parent onChanged with the updated IQueryFilter object
*************************************************************************************/
private onAnyChange() {
if(this.props.onChanged) {
this.props.onChanged(this.state.filter, this.props.index);
}
}
/*************************************************************************************
* Returns the options for the field Dropdown component
*************************************************************************************/
private getFieldDropdownOptions(): IDropdownOption[] {
let options:IDropdownOption[] = [
{ key: "", text: this.props.strings.fieldSelectLabel }
];
for(let field of this.props.fields) {
let option:IDropdownOption = { key: field.internalName, text: Text.format("{0} \{\{{1}\}\}", field.displayName, field.internalName) };
options.push(option);
}
return options;
}
/*************************************************************************************
* Returns the options for the operator Dropdown component
*************************************************************************************/
private getOperatorDropdownOptions(): IDropdownOption[] {
let fieldType = this.state.filter.field ? this.state.filter.field.type : QueryFilterFieldType.Text;
let options:IDropdownOption[];
// Operators for User and Taxonomy field types
if(fieldType == QueryFilterFieldType.User || fieldType == QueryFilterFieldType.Taxonomy) {
options = [
{ key: QueryFilterOperator[QueryFilterOperator.ContainsAny], text: this.props.strings.operatorContainsAnyLabel },
{ key: QueryFilterOperator[QueryFilterOperator.ContainsAll], text: this.props.strings.operatorContainsAllLabel },
{ key: QueryFilterOperator[QueryFilterOperator.IsNull], text: this.props.strings.operatorIsNullLabel },
{ key: QueryFilterOperator[QueryFilterOperator.IsNotNull], text: this.props.strings.operatorIsNotNullLabel }
];
}
// Operators for Text, Number, Datetime and Lookup field types
else {
options = [
{ key: QueryFilterOperator[QueryFilterOperator.Eq], text: this.props.strings.operatorEqualLabel },
{ key: QueryFilterOperator[QueryFilterOperator.Neq], text: this.props.strings.operatorNotEqualLabel },
{ key: QueryFilterOperator[QueryFilterOperator.Gt], text: this.props.strings.operatorGreaterLabel },
{ key: QueryFilterOperator[QueryFilterOperator.Lt], text: this.props.strings.operatorLessLabel },
{ key: QueryFilterOperator[QueryFilterOperator.Geq], text: this.props.strings.operatorGreaterEqualLabel },
{ key: QueryFilterOperator[QueryFilterOperator.Leq], text: this.props.strings.operatorLessEqualLabel },
{ key: QueryFilterOperator[QueryFilterOperator.IsNull], text: this.props.strings.operatorIsNullLabel },
{ key: QueryFilterOperator[QueryFilterOperator.IsNotNull], text: this.props.strings.operatorIsNotNullLabel }
];
// Specific operators for text field type
if(fieldType == QueryFilterFieldType.Text) {
options = options.concat([
{ key: QueryFilterOperator[QueryFilterOperator.BeginsWith], text: this.props.strings.operatorBeginsWithLabel },
{ key: QueryFilterOperator[QueryFilterOperator.Contains], text: this.props.strings.operatorContainsLabel }
]);
}
}
return options;
}
/*************************************************************************************
* Returns the options for the operator Dropdown component
*************************************************************************************/
private getJoinGroupOptions(): IChoiceGroupOption[] {
let options:IChoiceGroupOption[] = [
{ key: QueryFilterJoin[QueryFilterJoin.And], text: this.props.strings.andLabel, checked: (this.state.filter.join == QueryFilterJoin.And) },
{ key: QueryFilterJoin[QueryFilterJoin.Or], text: this.props.strings.orLabel, checked: (this.state.filter.join == QueryFilterJoin.Or) }
];
return options;
}
/*************************************************************************************
* Returns the user suggestions based on the specified user-entered filter
*************************************************************************************/
private onLoadPeoplePickerSuggestions(filterText: string, currentPersonas: IPersonaProps[], limitResults?: number) {
if(isEmpty(filterText)) {
return [];
}
return this.props.onLoadPeoplePickerSuggestions(filterText, currentPersonas, limitResults);
}
/*************************************************************************************
* Returns the tag suggestions based on the specified user-entered filter
*************************************************************************************/
private onLoadTagPickerSuggestions(filterText: string, currentTerms: ITag[]) {
if(isEmpty(filterText)) {
return [];
}
return this.props.onLoadTaxonomyPickerSuggestions(this.state.filter.field, filterText, currentTerms);
}
/*************************************************************************************
* Converts the specified filter value into a Date object if valid, otherwise null
* @param dateValue : The filter value that must be transformed into a Date object
*************************************************************************************/
private getDatePickerValue(dateValue: string | Date | IPersonaProps[] | ITag[]): Date {
if(dateValue instanceof Date) {
return dateValue;
}
else if(typeof(dateValue) === 'string') {
let date = moment(dateValue, moment.ISO_8601, true);
if(date.isValid()) {
return date.toDate();
}
}
return null;
}
/*************************************************************************************
* Converts the date resolved by the DatePicker into a formatted string
* @param date : The date resolved by the DatePicker
*************************************************************************************/
private onDatePickerFormat(date: Date): string {
return moment(date).format(this.props.strings.datePickerFormat);
}
/*************************************************************************************
* Converts the string manually entered by the user in the people picker to a Date
* @param dateStr : The string that must be parsed to a Date object
*************************************************************************************/
private onDatePickerParse(dateStr: string) : Date {
let date = moment(dateStr, this.props.strings.datePickerFormat, true);
return date.toDate();
}
/*************************************************************************************
* Renders the the QueryFilter component
*************************************************************************************/
public render() {
const filterFieldKey = this.state.filter.field != null ? this.state.filter.field.internalName : "";
const datePickerValue = this.getDatePickerValue(this.state.filter.value);
const hideValueSection = this.state.filter.operator == QueryFilterOperator.IsNull || this.state.filter.operator == QueryFilterOperator.IsNotNull;
const showTextField = (!this.state.filter.field || (this.state.filter.field.type == QueryFilterFieldType.Text || this.state.filter.field.type == QueryFilterFieldType.Number || this.state.filter.field.type == QueryFilterFieldType.Lookup)) && !hideValueSection;
const showPeoplePicker = this.state.filter.field && this.state.filter.field.type == QueryFilterFieldType.User && !hideValueSection;
const showTaxonomyPicker = this.state.filter.field && this.state.filter.field.type == QueryFilterFieldType.Taxonomy && !hideValueSection;
const showDatePicker = this.state.filter.field && this.state.filter.field.type == QueryFilterFieldType.Datetime && !hideValueSection;
const taxonomyPickerSuggestionProps: IBasePickerSuggestionsProps = {
suggestionsHeaderText: this.props.strings.taxonomyPickerSuggestionHeader,
noResultsFoundText: this.props.strings.taxonomyPickerNoResults,
loadingText: this.props.strings.taxonomyPickerLoading
};
const peoplePickerSuggestionProps: IBasePickerSuggestionsProps = {
suggestionsHeaderText: this.props.strings.peoplePickerSuggestionHeader,
noResultsFoundText: this.props.strings.peoplePickerNoResults,
loadingText: this.props.strings.peoplePickerLoading
};
return (
<div className={styles.queryFilter + ' ' + (this.props.disabled ? styles.disabled : '')}>
<div className={styles.paddingContainer}>
<Dropdown label={this.props.strings.fieldLabel}
disabled={this.props.disabled}
onChanged={this.onFieldDropdownChange.bind(this)}
selectedKey={filterFieldKey}
options={this.getFieldDropdownOptions()} />
<Dropdown label={this.props.strings.operatorLabel}
disabled={this.props.disabled}
onChanged={this.onOperatorDropdownChange.bind(this)}
selectedKey={QueryFilterOperator[this.state.filter.operator]}
options={this.getOperatorDropdownOptions()} />
{ showTextField &&
<TextField label={this.props.strings.valueLabel}
disabled={this.props.disabled}
onGetErrorMessage={ this.onValueTextFieldChange.bind(this) }
deferredValidationTime={500}
value={ this.state.filter.value != null ? this.state.filter.value as string : '' } />
}
{ showPeoplePicker &&
<div>
<Label>{ this.props.strings.valueLabel }</Label>
<NormalPeoplePicker
onResolveSuggestions={ this.onLoadPeoplePickerSuggestions.bind(this) }
onChange={ this.onPeoplePickerResolve.bind(this) }
defaultSelectedItems= { this.state.filter.value as IPersonaProps[] }
getTextFromItem={ (user: IPersonaProps) => user.primaryText }
pickerSuggestionsProps={ peoplePickerSuggestionProps }
className={ styles.peoplePicker + (this.state.filter.me ? ' ' + styles.disabled : '') }
inputProps={{ disabled: this.state.filter.me }}
key={ "peoplePicker" + this.state.pickersKey } />
<Checkbox
label={ this.props.strings.peoplePickerMe }
onChange={ this.onPeoplePickerCheckboxChange.bind(this) }
checked={ this.state.filter.me } />
</div>
}
{ showTaxonomyPicker &&
<div>
<Label>{ this.props.strings.valueLabel }</Label>
<TagPicker
onResolveSuggestions={ this.onLoadTagPickerSuggestions.bind(this) }
onChange={ this.onTaxonomyPickerResolve.bind(this) }
defaultSelectedItems= { this.state.filter.value as ITag[] }
getTextFromItem={ (term: ITag) => term.name }
pickerSuggestionsProps={ taxonomyPickerSuggestionProps }
key={ "taxonomyPicker" + this.state.pickersKey } />
</div>
}
{ showDatePicker &&
<div>
<DatePicker
label={ this.props.strings.valueLabel }
placeholder={ this.props.strings.datePickerDatePlaceholder }
allowTextInput={ true }
value={ datePickerValue }
formatDate={ this.onDatePickerFormat.bind(this) }
parseDateFromString={ this.onDatePickerParse.bind(this) }
onSelectDate={ this.onDatePickerChange.bind(this) }
strings={ this.props.strings.datePickerStrings } />
<TextField
placeholder={ this.props.strings.datePickerExpressionPlaceholder }
onGetErrorMessage={ this.onDateExpressionChange.bind(this) }
deferredValidationTime={ 500 }
value={ this.state.filter.expression || '' } />
<Checkbox
label={ this.props.strings.datePickerIncludeTime }
onChange={ this.onDateIncludeTimeChange.bind(this) }
checked={ this.state.filter.includeTime } />
</div>
}
<ChoiceGroup options={this.getJoinGroupOptions()}
onChange={this.onJoinChoiceChange.bind(this)}
disabled={this.props.disabled} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,9 @@
export enum QueryFilterFieldType {
Text = 1,
Number= 2,
Datetime = 3,
User = 4,
Lookup = 5,
Taxonomy = 6,
Url = 7
}

View File

@ -0,0 +1,4 @@
export enum QueryFilterJoin {
And = 1,
Or = 2
}

View File

@ -0,0 +1,14 @@
export enum QueryFilterOperator {
Eq = 1,
Neq = 2,
Gt = 3,
Lt = 4,
Geq = 5,
Leq = 6,
Contains = 7,
BeginsWith = 8,
ContainsAll = 9,
ContainsAny = 10,
IsNull = 11,
IsNotNull = 12
}

View File

@ -0,0 +1,16 @@
import { IPersonaProps, ITag } from 'office-ui-fabric-react';
import { IQueryFilter } from '../QueryFilter/IQueryFilter';
import { IQueryFilterField } from '../QueryFilter/IQueryFilterField';
import { IQueryFilterPanelStrings } from './IQueryFilterPanelStrings';
export interface IQueryFilterPanelProps {
filters: IQueryFilter[];
loadFields: () => Promise<IQueryFilterField[]>;
onLoadTaxonomyPickerSuggestions: (field: IQueryFilterField, filterText: string, currentTerms: ITag[]) => Promise<ITag[]>;
onLoadPeoplePickerSuggestions: (filterText: string, currentPersonas: IPersonaProps[], limitResults?: number) => Promise<IPersonaProps[]>;
onChanged?: (filters: IQueryFilter[]) => void;
disabled?: boolean;
trimEmptyFiltersOnChange?: boolean;
strings: IQueryFilterPanelStrings;
stateKey?: string;
}

View File

@ -0,0 +1,9 @@
import { IQueryFilter } from '../QueryFilter/IQueryFilter';
import { IQueryFilterField } from '../QueryFilter/IQueryFilterField';
export interface IQueryFilterPanelState {
loading: boolean;
fields: IQueryFilterField[];
filters: IQueryFilter[];
error: string;
}

View File

@ -0,0 +1,9 @@
import { IQueryFilterStrings } from '../QueryFilter/IQueryFilterStrings';
export interface IQueryFilterPanelStrings {
filtersLabel: string;
loadingFieldsLabel: string;
loadingFieldsErrorLabel: string;
addFilterLabel: string;
queryFilterStrings: IQueryFilterStrings;
}

View File

@ -0,0 +1,16 @@
.queryFilterPanel {
.queryFilterPanelItems {
.queryFilterPanelItem {
margin-top: 15px;
&:first-child {
margin-top: 0px;
}
}
}
>button {
margin-top: 15px;
}
}

View File

@ -0,0 +1,221 @@
import * as React from 'react';
import { cloneDeep } from '@microsoft/sp-lodash-subset';
import { Text } from '@microsoft/sp-core-library';
import { isEmpty } from '@microsoft/sp-lodash-subset';
import { Spinner, Button, ButtonType, Label } from 'office-ui-fabric-react';
import { QueryFilter } from '../QueryFilter/QueryFilter';
import { IQueryFilter } from '../QueryFilter/IQueryFilter';
import { QueryFilterOperator } from '../QueryFilter/QueryFilterOperator';
import { QueryFilterJoin } from '../QueryFilter/QueryFilterJoin';
import { IQueryFilterField } from '../QueryFilter/IQueryFilterField';
import { IQueryFilterPanelProps } from './IQueryFilterPanelProps';
import { IQueryFilterPanelState } from './IQueryFilterPanelState';
import styles from './QueryFilterPanel.module.scss';
export class QueryFilterPanel extends React.Component<IQueryFilterPanelProps, IQueryFilterPanelState> {
/*************************************************************************************
* Adds a default filter that always appears
*************************************************************************************/
private defaultFilters:IQueryFilter[] = [
{ field: null, operator: QueryFilterOperator.Eq, join: QueryFilterJoin.Or, value: '' }
];
/*************************************************************************************
* Component's constructor
* @param props
* @param state
*************************************************************************************/
constructor(props: IQueryFilterPanelProps, state: IQueryFilterPanelState) {
super(props);
this.state = {
loading: true,
fields: [],
filters: this.getDefaultFilters(),
error: null
};
this.getDefaultFilters = this.getDefaultFilters.bind(this);
this.loadFields = this.loadFields.bind(this);
}
/*************************************************************************************
* Returns a default array with an empty filter
*************************************************************************************/
private getDefaultFilters():IQueryFilter[] {
if(this.props.filters != null && this.props.filters.length > 0) {
return this.props.filters;
}
let defaultFilters:IQueryFilter[] = [
{ field: null, operator: QueryFilterOperator.Eq, join: QueryFilterJoin.Or, value: '' }
];
return defaultFilters;
}
/*************************************************************************************
* Called once after initial rendering
*************************************************************************************/
public componentDidMount(): void {
this.loadFields();
}
/*************************************************************************************
* Called immediately after updating occurs
*************************************************************************************/
public componentDidUpdate(prevProps: IQueryFilterPanelProps, prevState: IQueryFilterPanelState): void {
if (this.props.disabled !== prevProps.disabled || this.props.stateKey !== prevProps.stateKey) {
this.loadFields();
}
}
/*************************************************************************************
* Loads the available fields asynchronously
*************************************************************************************/
private loadFields(): void {
this.setState((prevState: IQueryFilterPanelState, props: IQueryFilterPanelProps): IQueryFilterPanelState => {
prevState.loading = true;
prevState.fields = new Array<IQueryFilterField>();
prevState.error = null;
prevState.filters = this.getDefaultFilters();
return prevState;
});
this.props.loadFields().then((fields: IQueryFilterField[]) => {
this.setState((prevState: IQueryFilterPanelState, props: IQueryFilterPanelProps): IQueryFilterPanelState => {
prevState.loading = false;
prevState.fields = fields;
return prevState;
});
})
.catch((error: any) => {
this.setState((prevState: IQueryFilterPanelState, props: IQueryFilterPanelProps): IQueryFilterPanelState => {
prevState.loading = false;
prevState.error = error;
return prevState;
});
});
}
/*************************************************************************************
* When one of the filter changes
*************************************************************************************/
private onFilterChanged(filter:IQueryFilter, index:number): void {
// Makes sure the parent is not notified for no reason if the modified filter was (and still is) considered empty
let isWorthNotifyingParent = true;
let oldFilter = this.state.filters[index];
if(this.props.trimEmptyFiltersOnChange && this.isFilterEmpty(oldFilter) && this.isFilterEmpty(filter)) {
isWorthNotifyingParent = false;
}
// Updates the modified filter in the state
this.state.filters[index] = cloneDeep(filter);
this.setState((prevState: IQueryFilterPanelState, props: IQueryFilterPanelProps): IQueryFilterPanelState => {
prevState.filters = this.state.filters;
return prevState;
});
// Notifies the parent with the updated filters
if(isWorthNotifyingParent) {
let filters:IQueryFilter[] = this.props.trimEmptyFiltersOnChange ? this.state.filters.filter((f) => { return !this.isFilterEmpty(f); }) : this.state.filters;
this.props.onChanged(filters);
}
}
/*************************************************************************************
* Returns whether the specified filter is empty or not
* @param filter : The filter that needs to be checked
*************************************************************************************/
private isFilterEmpty(filter:IQueryFilter) {
let isFilterEmpty = false;
// If the filter has no field
if(filter.field == null) {
isFilterEmpty = true;
}
// If the filter has a null or empty value
if(filter.value == null || isEmpty(filter.value.toString())) {
// And has no date time expression
if(isEmpty(filter.expression)) {
// And isn't a [Me] switch
if(!filter.me) {
// And isn't a <IsNull /> or <IsNotNull /> operator
if(filter.operator != QueryFilterOperator.IsNull && filter.operator != QueryFilterOperator.IsNotNull) {
isFilterEmpty = true;
}
}
}
}
return isFilterEmpty;
}
/*************************************************************************************
* When the 'Add filter' button is clicked
*************************************************************************************/
private onAddFilterClick(): void {
// Updates the state with an all fresh new filter
let newFilter:IQueryFilter = { field: null, operator: QueryFilterOperator.Eq, join: QueryFilterJoin.Or, value: '' };
this.state.filters.push(newFilter);
this.setState((prevState: IQueryFilterPanelState, props: IQueryFilterPanelProps): IQueryFilterPanelState => {
prevState.filters = this.state.filters;
return prevState;
});
}
/*************************************************************************************
* Renders the the QueryFilter component
*************************************************************************************/
public render() {
const loading = this.state.loading ? <Spinner label={this.props.strings.loadingFieldsLabel} /> : <div />;
const error = this.state.error != null ? <div className="ms-TextField-errorMessage ms-u-slideDownIn20">{ Text.format(this.props.strings.loadingFieldsErrorLabel, this.state.error) }</div> : <div />;
const filters = this.state.filters.map((filter, index) =>
<div className={styles.queryFilterPanelItem} key={index}>
<QueryFilter fields={this.state.fields}
filter={filter}
disabled={this.props.disabled}
onLoadTaxonomyPickerSuggestions={this.props.onLoadTaxonomyPickerSuggestions}
onLoadPeoplePickerSuggestions={this.props.onLoadPeoplePickerSuggestions}
onChanged={this.onFilterChanged.bind(this)}
strings={this.props.strings.queryFilterStrings}
index={index} />
</div>
);
return (
<div className={styles.queryFilterPanel}>
<Label>{this.props.strings.filtersLabel}</Label>
{loading}
{ !this.state.loading &&
<div className={styles.queryFilterPanelItems}>{filters}</div>
}
{ !this.state.loading &&
<Button buttonType={ButtonType.primary} onClick={this.onAddFilterClick.bind(this)} disabled={this.props.disabled} icon='Add'>{this.props.strings.addFilterLabel}</Button>
}
{error}
</div>
);
}
}

View File

@ -0,0 +1,23 @@
{
"$schema": "../../../node_modules/@microsoft/sp-module-interfaces/lib/manifestSchemas/jsonSchemas/clientSideComponentManifestSchema.json",
"id": "46edf08f-95c7-4ca7-9146-6471f9f471be",
"alias": "ContentQueryWebPart",
"componentType": "WebPart",
"version": "0.0.1",
"manifestVersion": 2,
"preconfiguredEntries": [{
"groupId": "46edf08f-95c7-4ca7-9146-6471f9f471be",
"group": { "default": "Under Development" },
"title": { "default": "Content by Query WebPart" },
"description": {
"default": "A react content by query WebPart for querying items within a site and easily displaying them using a simple yet powerfull HandleBars templating engine.",
"fr-FR": "Un composante React permettant d'effectuer des requêtes sur les items et de facilement afficher les résultat à l'aide de gabarits HandleBars fournit par l'utilisateur"
},
"officeFabricIconFontName": "Page",
"properties": {
"description": "Content by Query WebPart"
}
}]
}

View File

@ -0,0 +1,508 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import * as strings from 'contentQueryStrings';
import { Version, Text, Log } from '@microsoft/sp-core-library';
import { BaseClientSideWebPart, IPropertyPaneConfiguration, IPropertyPaneField } from '@microsoft/sp-webpart-base';
import { PropertyPaneTextField, IPropertyPaneTextFieldProps } from '@microsoft/sp-webpart-base';
import { PropertyPaneChoiceGroup, IPropertyPaneChoiceGroupProps } from '@microsoft/sp-webpart-base';
import { PropertyPaneToggle, IPropertyPaneToggleProps } from '@microsoft/sp-webpart-base';
import { update, get, isEmpty } from '@microsoft/sp-lodash-subset';
import { IDropdownOption, IPersonaProps, ITag } from 'office-ui-fabric-react';
import ContentQuery from './components/ContentQuery';
import { IContentQueryProps } from './components/IContentQueryProps';
import { IQuerySettings } from './components/IQuerySettings';
import { IContentQueryTemplateContext } from './components/IContentQueryTemplateContext';
import { IContentQueryWebPartProps } from './IContentQueryWebPartProps';
import { PropertyPaneAsyncDropdown } from '../../controls/PropertyPaneAsyncDropdown/PropertyPaneAsyncDropdown';
import { PropertyPaneQueryFilterPanel } from '../../controls/PropertyPaneQueryFilterPanel/PropertyPaneQueryFilterPanel';
import { PropertyPaneAsyncChecklist } from '../../controls/PropertyPaneAsyncChecklist/PropertyPaneAsyncChecklist';
import { IQueryFilter } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/IQueryFilter';
import { IQueryFilterField } from '../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/IQueryFilterField';
import { IChecklistItem } from '../../controls/PropertyPaneAsyncChecklist/components/AsyncChecklist/IChecklistItem';
import { ContentQueryService } from '../../common/services/ContentQueryService';
import { IContentQueryService } from '../../common/services/IContentQueryService';
import { ContentQueryConstants } from '../../common/constants/ContentQueryConstants';
export default class ContentQueryWebPart extends BaseClientSideWebPart<IContentQueryWebPartProps> {
private readonly logSource = "ContentQueryWebPart.ts";
/***************************************************************************
* Service used to perform REST calls
***************************************************************************/
private ContentQueryService: IContentQueryService;
/***************************************************************************
* Custom ToolPart Property Panes
***************************************************************************/
private webUrlDropdown: PropertyPaneAsyncDropdown;
private listTitleDropdown: PropertyPaneAsyncDropdown;
private orderByDropdown: PropertyPaneAsyncDropdown;
private orderByDirectionChoiceGroup: IPropertyPaneField<IPropertyPaneChoiceGroupProps>;
private limitEnabledToggle: IPropertyPaneField<IPropertyPaneToggleProps>;
private itemLimitTextField: IPropertyPaneField<IPropertyPaneTextFieldProps>;
private filtersPanel: PropertyPaneQueryFilterPanel;
private viewFieldsChecklist: PropertyPaneAsyncChecklist;
private templateUrlTextField: IPropertyPaneField<IPropertyPaneTextFieldProps>;
/***************************************************************************
* Returns the WebPart's version
***************************************************************************/
protected get dataVersion(): Version {
return Version.parse('1.0');
}
/***************************************************************************
* Initializes the WebPart
***************************************************************************/
protected onInit(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.ContentQueryService = new ContentQueryService(this.context, this.context.spHttpClient);
resolve();
});
}
/***************************************************************************
* Renders the WebPart
***************************************************************************/
public render(): void {
let querySettings: IQuerySettings = {
webUrl: this.properties.webUrl,
listTitle: this.properties.listTitle,
limitEnabled: this.properties.limitEnabled,
itemLimit: this.properties.itemLimit,
orderBy: this.properties.orderBy,
orderByDirection: this.properties.orderByDirection,
filters: this.properties.filters,
viewFields: this.properties.viewFields,
};
const element: React.ReactElement<IContentQueryProps> = React.createElement(ContentQuery,
{
onLoadTemplate: this.loadTemplate.bind(this),
onLoadTemplateContext: this.loadTemplateContext.bind(this),
querySettings: querySettings,
templateUrl: this.properties.templateUrl,
strings: strings.contentQueryStrings,
stateKey: new Date().toString()
}
);
ReactDom.render(element, this.domElement);
}
/***************************************************************************
* Loads the toolpart configuration
***************************************************************************/
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
let firstCascadingLevelDisabled = !this.properties.webUrl;
let secondCascadingLevelDisabled = !this.properties.webUrl || !this.properties.listTitle;
// Creates a custom PropertyPaneAsyncDropdown for the webUrl property
this.webUrlDropdown = new PropertyPaneAsyncDropdown(ContentQueryConstants.propertyWebUrl, {
label: strings.WebUrlFieldLabel,
loadingLabel: strings.WebUrlFieldLoadingLabel,
errorLabelFormat: strings.WebUrlFieldLoadingError,
loadOptions: this.loadWebUrlOptions.bind(this),
onPropertyChange: this.onWebUrlChange.bind(this),
selectedKey: this.properties.webUrl || ""
});
// Creates a custom PropertyPaneAsyncDropdown for the listTitle property
this.listTitleDropdown = new PropertyPaneAsyncDropdown(ContentQueryConstants.propertyListTitle, {
label: strings.ListTitleFieldLabel,
loadingLabel: strings.ListTitleFieldLoadingLabel,
errorLabelFormat: strings.ListTitleFieldLoadingError,
loadOptions: this.loadListTitleOptions.bind(this),
onPropertyChange: this.onListTitleChange.bind(this),
selectedKey: this.properties.listTitle || "",
disabled: firstCascadingLevelDisabled
});
// Creates a custom PropertyPaneAsyncDropdown for the orderBy property
this.orderByDropdown = new PropertyPaneAsyncDropdown(ContentQueryConstants.propertyOrderBy, {
label: strings.OrderByFieldLabel,
loadingLabel: strings.OrderByFieldLoadingLabel,
errorLabelFormat: strings.OrderByFieldLoadingError,
loadOptions: this.loadOrderByOptions.bind(this),
onPropertyChange: this.onOrderByChange.bind(this),
selectedKey: this.properties.orderBy || "",
disabled: secondCascadingLevelDisabled
});
// Creates a custom PropertyPaneQueryFilterPanel for the filters property
this.filtersPanel = new PropertyPaneQueryFilterPanel(ContentQueryConstants.propertyFilters, {
filters: this.properties.filters,
loadFields: this.loadFilterFields.bind(this),
onLoadTaxonomyPickerSuggestions: this.loadTaxonomyPickerSuggestions.bind(this),
onLoadPeoplePickerSuggestions: this.loadPeoplePickerSuggestions.bind(this),
onPropertyChange: this.onFiltersChange.bind(this),
trimEmptyFiltersOnChange: true,
disabled: secondCascadingLevelDisabled,
strings: strings.queryFilterPanelStrings
});
// Creates a custom PropertyPaneAsyncChecklist for the viewFields property
this.viewFieldsChecklist = new PropertyPaneAsyncChecklist(ContentQueryConstants.propertyViewFields, {
loadItems: this.loadViewFieldsChecklistItems.bind(this),
checkedItems: this.properties.viewFields,
onPropertyChange: this.onViewFieldsChange.bind(this),
disable: secondCascadingLevelDisabled,
strings: strings.viewFieldsChecklistStrings
});
// Creates a PropertyPaneChoiceGroup for the orderByDirection property
this.orderByDirectionChoiceGroup = PropertyPaneChoiceGroup(ContentQueryConstants.propertOrderByDirection, {
options: [
{ text: strings.ShowItemsAscending, key: 'asc', checked: !this.properties.orderByDirection || this.properties.orderByDirection == 'asc', disabled: secondCascadingLevelDisabled },
{ text: strings.ShowItemsDescending, key: 'desc', checked: this.properties.orderByDirection == 'desc', disabled: secondCascadingLevelDisabled }
]
});
// Creates a PropertyPaneTextField for the templateUrl property
this.templateUrlTextField = PropertyPaneTextField(ContentQueryConstants.propertyTemplateUrl, {
label: strings.TemplateUrlFieldLabel,
placeholder: strings.TemplateUrlPlaceholder,
deferredValidationTime: 500,
onGetErrorMessage: this.onTemplateUrlChange.bind(this)
});
// Creates a PropertyPaneToggle for the limitEnabled property
this.limitEnabledToggle = PropertyPaneToggle(ContentQueryConstants.propertyLimitEnabled, {
label: strings.LimitEnabledFieldLabel,
offText: 'Disabled',
onText: 'Enabled',
checked: this.properties.limitEnabled,
disabled: secondCascadingLevelDisabled
});
// Creates a PropertyPaneTextField for the itemLimit property
this.itemLimitTextField = PropertyPaneTextField(ContentQueryConstants.propertyItemLimit, {
deferredValidationTime: 500,
placeholder: strings.ItemLimitPlaceholder,
disabled: !this.properties.limitEnabled || secondCascadingLevelDisabled,
onGetErrorMessage: this.onItemLimitChange.bind(this)
});
return {
pages: [
{
header: { description: strings.SourcePageDescription },
groups: [
{
groupName: strings.SourceGroupName,
groupFields: [
this.webUrlDropdown,
this.listTitleDropdown
]
}
]
},
{
header: { description: strings.QueryPageDescription },
groups: [
{
groupName: strings.QueryGroupName,
groupFields: [
this.orderByDropdown,
this.orderByDirectionChoiceGroup,
this.limitEnabledToggle,
this.itemLimitTextField,
this.filtersPanel
]
}
]
},
{
header: { description: strings.DisplayPageDescription },
groups: [
{
groupName: strings.DisplayGroupName,
groupFields: [
this.viewFieldsChecklist,
this.templateUrlTextField
]
}
]
}
]
};
}
/***************************************************************************
* Loads the HandleBars template from the specified url
***************************************************************************/
private loadTemplate(templateUrl:string): Promise<string> {
return this.ContentQueryService.getFileContent(templateUrl);
}
/***************************************************************************
* Loads the HandleBars context based on the specified query
***************************************************************************/
private loadTemplateContext(querySettings:IQuerySettings, callTimeStamp: number): Promise<IContentQueryTemplateContext> {
return this.ContentQueryService.getTemplateContext(querySettings, callTimeStamp);
}
/***************************************************************************
* Loads the dropdown options for the webUrl property
***************************************************************************/
private loadWebUrlOptions(): Promise<IDropdownOption[]> {
return this.ContentQueryService.getWebUrlOptions();
}
/***************************************************************************
* Loads the dropdown options for the listTitle property
***************************************************************************/
private loadListTitleOptions(): Promise<IDropdownOption[]> {
return this.ContentQueryService.getListTitleOptions(this.properties.webUrl);
}
/***************************************************************************
* Loads the dropdown options for the orderBy property
***************************************************************************/
private loadOrderByOptions(): Promise<IDropdownOption[]> {
return this.ContentQueryService.getOrderByOptions(this.properties.webUrl, this.properties.listTitle);
}
/***************************************************************************
* Loads the dropdown options for the listTitle property
***************************************************************************/
private loadFilterFields():Promise<IQueryFilterField[]> {
return this.ContentQueryService.getFilterFields(this.properties.webUrl, this.properties.listTitle);
}
/***************************************************************************
* Loads the checklist items for the viewFields property
***************************************************************************/
private loadViewFieldsChecklistItems():Promise<IChecklistItem[]> {
return this.ContentQueryService.getViewFieldsChecklistItems(this.properties.webUrl, this.properties.listTitle);
}
/***************************************************************************
* Returns the user suggestions based on the user entered picker input
* @param filterText : The filter specified by the user in the people picker
* @param currentPersonas : The IPersonaProps already selected in the people picker
* @param limitResults : The results limit if any
***************************************************************************/
private loadPeoplePickerSuggestions(filterText: string, currentPersonas: IPersonaProps[], limitResults?: number):Promise<IPersonaProps[]> {
return this.ContentQueryService.getPeoplePickerSuggestions(this.properties.webUrl, filterText, currentPersonas, limitResults);
}
/***************************************************************************
* Returns the taxonomy suggestions based on the user entered picker input
* @param field : The taxonomy field from which to load the terms from
* @param filterText : The filter specified by the user in the people picker
* @param currentPersonas : The IPersonaProps already selected in the people picker
* @param limitResults : The results limit if any
***************************************************************************/
private loadTaxonomyPickerSuggestions(field: IQueryFilterField, filterText: string, currentTerms: ITag[]):Promise<ITag[]> {
return this.ContentQueryService.getTaxonomyPickerSuggestions(this.properties.webUrl, this.properties.listTitle, field, filterText, currentTerms);
}
/***************************************************************************
* Handles the change of the webUrl property
***************************************************************************/
private onWebUrlChange(propertyPath: string, newValue: any): void {
Log.verbose(this.logSource, "WebPart property 'webUrl' has changed, refreshing WebPart...", this.context.serviceScope);
const oldValue = get(this.properties, propertyPath);
// Stores the new value in web part properties
update(this.properties, propertyPath, (): any => { return newValue; });
// Resets the web-dependent property panes
this.resetListTitlePropertyPane();
this.resetOrderByPropertyPane();
this.resetFiltersPropertyPane();
this.resetViewFieldsPropertyPane();
// Refreshes the web part
this.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
}
/***************************************************************************
* Handles the change of the listTitle property
***************************************************************************/
private onListTitleChange(propertyPath: string, newValue: any): void {
Log.verbose(this.logSource, "WebPart property 'listTitle' has changed, refreshing WebPart...", this.context.serviceScope);
const oldValue = get(this.properties, propertyPath);
// Stores the new value in web part properties
update(this.properties, propertyPath, (): any => { return newValue; });
// Resets the list-dependent property panes
this.resetOrderByPropertyPane();
this.resetFiltersPropertyPane();
this.resetViewFieldsPropertyPane();
// refresh web part
this.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
}
/***************************************************************************
* Handles the change of the orderBy property
***************************************************************************/
private onOrderByChange(propertyPath: string, newValue: string): void {
Log.verbose(this.logSource, "WebPart property 'orderBy' has changed, refreshing WebPart...", this.context.serviceScope);
const oldValue = get(this.properties, propertyPath);
// Stores the new value in web part properties
update(this.properties, propertyPath, (): any => { return newValue; });
// refresh web part
this.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
}
/***************************************************************************
* Handles the change of the filters property
***************************************************************************/
private onFiltersChange(propertyPath: string, newFilters:IQueryFilter[]) {
Log.verbose(this.logSource, "WebPart property 'filters' has changed, refreshing WebPart...", this.context.serviceScope);
const oldValue = get(this.properties, propertyPath);
// Stores the new value in web part properties
update(this.properties, propertyPath, (): any => { return newFilters; });
// refresh web part
this.onPropertyPaneFieldChanged(propertyPath, oldValue, newFilters);
}
/***************************************************************************
* Handles the change of the viewFields property
***************************************************************************/
private onViewFieldsChange(propertyPath: string, checkedKeys: string[]) {
Log.verbose(this.logSource, "WebPart property 'viewFields' has changed, refreshing WebPart...", this.context.serviceScope);
const oldValue = get(this.properties, propertyPath);
// Stores the new value in web part properties
update(this.properties, propertyPath, (): any => { return checkedKeys; });
// refresh web part
this.onPropertyPaneFieldChanged(propertyPath, oldValue, checkedKeys);
}
/***************************************************************************
* Validates the templateUrl property
***************************************************************************/
private onTemplateUrlChange(value: string): Promise<String> {
Log.verbose(this.logSource, "WebPart property 'templateUrl' has changed, refreshing WebPart...", this.context.serviceScope);
return new Promise<string>((resolve, reject) => {
// Doesn't raise any error if file is empty (otherwise error message will show on initial load...)
if(isEmpty(value)) {
resolve('');
}
// Resolves an error if the file isn't a valid .htm or .html file
else if(!this.ContentQueryService.isValidTemplateFile(value)) {
resolve(strings.ErrorTemplateExtension);
}
// Resolves an error if the file doesn't answer a simple head request
else {
this.ContentQueryService.ensureFileResolves(value).then((isFileResolving:boolean) => {
resolve('');
})
.catch((error) => {
resolve(Text.format(strings.ErrorTemplateResolve, error));
});
}
});
}
/***************************************************************************
* Validates the itemLimit property
***************************************************************************/
private onItemLimitChange(value: string): Promise<String> {
Log.verbose(this.logSource, "WebPart property 'itemLimit' has changed, refreshing WebPart...", this.context.serviceScope);
return new Promise<string>((resolve, reject) => {
// Resolves an error if the file isn't a valid number between 1 to 999
let parsedValue = parseInt(value);
let isNumeric = !isNaN(parsedValue) && isFinite(parsedValue);
let isValid = (isNumeric && parsedValue >= 1 && parsedValue <= 999) || isEmpty(value);
resolve(!isValid ? strings.ErrorItemLimit : '');
});
}
/***************************************************************************
* Resets the List Title property pane and re-renders it
***************************************************************************/
private resetListTitlePropertyPane() {
Log.verbose(this.logSource, "Resetting 'listTitle' property...", this.context.serviceScope);
this.properties.listTitle = null;
this.ContentQueryService.clearCachedListTitleOptions();
update(this.properties, ContentQueryConstants.propertyListTitle, (): any => { return this.properties.listTitle; });
this.listTitleDropdown.properties.selectedKey = "";
this.listTitleDropdown.properties.disabled = isEmpty(this.properties.webUrl);
this.listTitleDropdown.render();
}
/***************************************************************************
* Resets the Filters property pane and re-renders it
***************************************************************************/
private resetOrderByPropertyPane() {
Log.verbose(this.logSource, "Resetting 'orderBy' property...", this.context.serviceScope);
this.properties.orderBy = null;
this.ContentQueryService.clearCachedOrderByOptions();
update(this.properties, ContentQueryConstants.propertyOrderBy, (): any => { return this.properties.orderBy; });
this.orderByDropdown.properties.selectedKey = "";
this.orderByDropdown.properties.disabled = isEmpty(this.properties.webUrl) || isEmpty(this.properties.listTitle);
this.orderByDropdown.render();
}
/***************************************************************************
* Resets the Filters property pane and re-renders it
***************************************************************************/
private resetFiltersPropertyPane() {
Log.verbose(this.logSource, "Resetting 'filters' property...", this.context.serviceScope);
this.properties.filters = null;
this.ContentQueryService.clearCachedFilterFields();
update(this.properties, ContentQueryConstants.propertyFilters, (): any => { return this.properties.filters; });
this.filtersPanel.properties.filters = null;
this.filtersPanel.properties.disabled = isEmpty(this.properties.webUrl) || isEmpty(this.properties.listTitle);
this.filtersPanel.render();
}
/***************************************************************************
* Resets the View Fields property pane and re-renders it
***************************************************************************/
private resetViewFieldsPropertyPane() {
Log.verbose(this.logSource, "Resetting 'viewFields' property...", this.context.serviceScope);
this.properties.viewFields = null;
this.ContentQueryService.clearCachedViewFields();
update(this.properties, ContentQueryConstants.propertyViewFields, (): any => { return this.properties.viewFields; });
this.viewFieldsChecklist.properties.checkedItems = null;
this.viewFieldsChecklist.properties.disable = isEmpty(this.properties.webUrl) || isEmpty(this.properties.listTitle);
this.viewFieldsChecklist.render();
}
}

View File

@ -0,0 +1,13 @@
import { IQueryFilter } from "../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/IQueryFilter";
export interface IContentQueryWebPartProps {
webUrl: string;
listTitle: string;
limitEnabled: boolean;
itemLimit: number;
orderBy: string;
orderByDirection: string;
filters: IQueryFilter[];
viewFields: string[];
templateUrl: string;
}

View File

@ -0,0 +1,22 @@
$container-bg-color: #f4f4f4;
$container-border: 1px solid #dadada;
.cqwp {
.cqwpValidations {
background-color: $container-bg-color;
border: $container-border;
padding: 20px 20px 15px 20px;
div[class*='ms-Checkbox'] {
&:first-child {
margin-top: 5px;
}
}
}
.cqwpError {
background-color: $container-bg-color;
border: $container-border;
padding: 20px;
text-align: center;
}
}

View File

@ -0,0 +1,190 @@
import * as React from 'react';
import * as Handlebars from "handlebars";
import * as strings from 'contentQueryStrings';
import { Checkbox, Spinner } from 'office-ui-fabric-react';
import { isEmpty } from '@microsoft/sp-lodash-subset';
import { Text } from '@microsoft/sp-core-library';
import { IContentQueryProps } from './IContentQueryProps';
import { IContentQueryState } from './IContentQueryState';
import { IContentQueryTemplateContext } from './IContentQueryTemplateContext';
import styles from './ContentQuery.module.scss';
export default class ContentQuery extends React.Component<IContentQueryProps, IContentQueryState> {
/*************************************************************************************
* Stores the timestamps of each async calls in order to wait for the last call in
* case multiple calls have been fired in a short lapse of time by updaing the
* toolpane too fast
*************************************************************************************/
private onGoingAsyncCalls: number[];
/*************************************************************************************
* Component's constructor
* @param props
* @param state
*************************************************************************************/
constructor(props: IContentQueryProps, state: IContentQueryState) {
super(props);
this.onGoingAsyncCalls = [];
this.state = { loading: true, processedTemplateResult: null, error: null };
}
/*************************************************************************************
* Returns whether the specified call is the LAST executed call within the stored calls
*************************************************************************************/
private isLastExecutedCall(timeStamp: number) {
return (this.onGoingAsyncCalls.length > 0 && this.onGoingAsyncCalls.filter((t: number) => { return t > timeStamp; }).length == 0);
}
/*************************************************************************************
* Loads the items asynchronously and wraps them into a context object for handlebars
*************************************************************************************/
private loadTemplateContext() {
if(this.areMandatoryFieldsConfigured()) {
// Stores the current call timestamp locally
let currentCallTimeStamp = new Date().valueOf();
this.onGoingAsyncCalls.push(currentCallTimeStamp);
// Resets the state if this is the first call
if(this.onGoingAsyncCalls.length == 1) {
this.setState({
loading: true,
processedTemplateResult: null,
error: null
});
}
// Fires the async call with its associated timestamp
this.props.onLoadTemplateContext(this.props.querySettings, currentCallTimeStamp).then((templateContext: IContentQueryTemplateContext) => {
// Loads the handlebars template
this.props.onLoadTemplate(this.props.templateUrl).then((templateContent: string) => {
// Only process the result of the current async call if it's the last in the ordered queue
if(this.isLastExecutedCall(templateContext.callTimeStamp)) {
// Resets the onGoingAsyncCalls
this.onGoingAsyncCalls = [];
// Process the handlebars template
this.processTemplate(templateContent, templateContext);
}
})
.catch((error: string) => {
this.setState({ loading: false, processedTemplateResult: null, error: Text.format(this.props.strings.errorLoadingTemplate, error) });
});
})
.catch((error) => {
this.setState({ loading: false, processedTemplateResult: null, error: Text.format(this.props.strings.errorLoadingQuery, error) });
});
}
else {
this.setState({ loading: false, processedTemplateResult: null, error: null });
}
}
/*************************************************************************************
* Process the specified handlebars template with the given template context
* @param templateContent : The handlebars template that needs to be compiled
* @param templateContext : The context that must be applied to the compiled template
*************************************************************************************/
private processTemplate(templateContent: string, templateContext: IContentQueryTemplateContext) {
try {
// Processes the template
let template = Handlebars.compile(templateContent);
let result = template(templateContext);
// Updates the state only if the stored calls are still empty (just in case they get updated during the processing of the handlebars template)
if(this.onGoingAsyncCalls.length == 0) {
this.setState({ loading: false, processedTemplateResult: result, error: null });
}
}
catch(error) {
this.setState({ loading: false, processedTemplateResult: null, error: Text.format(this.props.strings.errorProcessingTemplate, error) });
}
}
/*************************************************************************************
* Returns whether all mandatory fields are configured or not
*************************************************************************************/
private areMandatoryFieldsConfigured(): boolean {
return !isEmpty(this.props.querySettings.webUrl) &&
!isEmpty(this.props.querySettings.listTitle) &&
!isEmpty(this.props.querySettings.viewFields) &&
!isEmpty(this.props.templateUrl);
}
/*************************************************************************************
* Converts the specified HTML by an object required for dangerouslySetInnerHTML
* @param html
*************************************************************************************/
private createMarkup(html: string) {
return {__html: html};
}
/*************************************************************************************
* Called once after initial rendering
*************************************************************************************/
public componentDidMount(): void {
this.loadTemplateContext();
}
/*************************************************************************************
* Gets called when the WebPart refreshes (because of the reactive mode for instance)
*************************************************************************************/
public componentDidUpdate(prevProps: IContentQueryProps, prevState: IContentQueryState): void {
if(prevProps.stateKey !== this.props.stateKey) {
this.loadTemplateContext();
}
}
/*************************************************************************************
* Renders the Content by Query WebPart
*************************************************************************************/
public render(): React.ReactElement<IContentQueryProps> {
const loading = this.state.loading ? <Spinner label={this.props.strings.loadingItems} /> : <div />;
const error = this.state.error ? <div className={styles.cqwpError}>{this.state.error}</div> : <div />;
const mandatoryFieldsConfigured = this.areMandatoryFieldsConfigured();
return (
<div className={styles.cqwp}>
{loading}
{error}
{/* Shows the validation checklist if mandatory properties aren't all configured */}
{ !mandatoryFieldsConfigured && !this.state.loading && !this.state.error &&
<div className={styles.cqwpValidations}>
Configure the following mandatory properties in order to display results :
<Checkbox label={strings.WebUrlFieldLabel} checked={!isEmpty(this.props.querySettings.webUrl)} />
<Checkbox label={strings.ListTitleFieldLabel} checked={!isEmpty(this.props.querySettings.listTitle)} />
<Checkbox label={strings.viewFieldsChecklistStrings.label} checked={!isEmpty(this.props.querySettings.viewFields)} />
<Checkbox label={strings.TemplateUrlFieldLabel} checked={!isEmpty(this.props.templateUrl)} />
</div>
}
{/* Shows the query results once loaded */}
{ mandatoryFieldsConfigured && !this.state.loading && !this.state.error &&
<div dangerouslySetInnerHTML={ this.createMarkup(this.state.processedTemplateResult) }></div>
}
</div>
);
}
}

View File

@ -0,0 +1,13 @@
import { IContentQueryTemplateContext } from './IContentQueryTemplateContext';
import { IContentQueryStrings } from './IContentQueryStrings';
import { IQuerySettings } from './IQuerySettings';
export interface IContentQueryProps {
onLoadTemplate: (templateUrl: string) => Promise<string>;
onLoadTemplateContext: (querySettings: IQuerySettings, callTimeStamp: number) => Promise<IContentQueryTemplateContext>;
querySettings: IQuerySettings;
templateUrl: string;
strings: IContentQueryStrings;
stateKey: string;
}

View File

@ -0,0 +1,5 @@
export interface IContentQueryState {
loading: boolean;
processedTemplateResult: string;
error: string;
}

View File

@ -0,0 +1,6 @@
export interface IContentQueryStrings {
loadingItems: string;
errorLoadingQuery: string;
errorLoadingTemplate: string;
errorProcessingTemplate: string;
}

View File

@ -0,0 +1,9 @@
import { PageContext } from '@microsoft/sp-page-context';
export interface IContentQueryTemplateContext {
pageContext: PageContext;
items: any[];
accessDenied: boolean;
webNotFound: boolean;
callTimeStamp: number;
}

View File

@ -0,0 +1,12 @@
import { IQueryFilter } from '../../../controls/PropertyPaneQueryFilterPanel/components/QueryFilter/IQueryFilter';
export interface IQuerySettings {
webUrl: string;
listTitle: string;
limitEnabled: boolean;
itemLimit: number;
orderBy: string;
orderByDirection: string;
filters: IQueryFilter[];
viewFields: string[];
}

View File

@ -0,0 +1,90 @@
define([], function() {
return {
SourcePageDescription: "Specify where the WebPart should get the results from.",
QueryPageDescription: "If needed, choose the sorting behavior, limit the results, or add filters in order to narrow the query down.",
DisplayPageDescription: "Finally, specify which fields should be available for rendering within the HandleBars template, and specify the URL of the said template.",
SourceGroupName: "Source",
QueryGroupName: "Query",
DisplayGroupName: "Display",
WebUrlFieldLabel: "Web Url",
WebUrlFieldPlaceholder: "Select the source web...",
WebUrlFieldLoadingLabel: "Loading webs from current site...",
WebUrlFieldLoadingError: "An error occured while loading webs : {0}",
ListTitleFieldLabel: "List Title",
ListTitleFieldPlaceholder: "Select the source list...",
ListTitleFieldLoadingLabel: "Loading lists from specified web...",
ListTitleFieldLoadingError: "An error occured while loading lists : {0}",
OrderByFieldLabel: "Order By",
OrderByFieldLoadingLabel: "Loading fields from specified list...",
OrderByFieldLoadingError: "An error occured while loading fields : {0}",
LimitEnabledFieldLabel: "Limit the number of items to display",
ItemLimitPlaceholder: "Enter a limit from 1 to 999",
ErrorItemLimit: "Value must be a number between 1 to 999",
TemplateUrlFieldLabel: "Template Url",
TemplateUrlPlaceholder: "Enter a valid HandleBars .htm file url",
ErrorTemplateExtension: "The template must be a valid .htm or .html file",
ErrorTemplateResolve: "Unable to resolve the specified template : {0}",
ErrorWebAccessDenied: "You do not have access to the previously configured web url '{0}'. Either leave the WebPart properties as is or select another web url.",
ErrorWebNotFound: "The previously configured web url '{0}' is not found anymore. Either leave the WebPart properties as is or select another web url.",
ErrorProcessingTemplate: "An error occured while processing the handlebars template : {0}",
ShowItemsAscending: "Show items in ascending order",
ShowItemsDescending: "Show items in descending order",
queryFilterPanelStrings: {
filtersLabel: "Filters",
addFilterLabel: "Add filter",
loadingFieldsLabel: "Loading fields from specified list...",
loadingFieldsErrorLabel: "An error occured while loading fields : {0}",
queryFilterStrings: {
fieldLabel: "Field",
fieldSelectLabel: "Select a field...",
operatorLabel: "Operator",
operatorEqualLabel: 'Equals',
operatorNotEqualLabel: 'Does not equal',
operatorGreaterLabel: 'Is greater than',
operatorGreaterEqualLabel: 'Is greater or equal to',
operatorLessLabel: 'Is less than',
operatorLessEqualLabel: 'Is less or equal to',
operatorContainsLabel: 'Contains',
operatorBeginsWithLabel: 'Begins with',
operatorContainsAnyLabel: 'Contains Any',
operatorContainsAllLabel: 'Contains All',
operatorIsNullLabel: 'Is Null',
operatorIsNotNullLabel: 'Is Not Null',
valueLabel: 'Value',
andLabel: 'And',
orLabel: 'Or',
peoplePickerSuggestionHeader: 'Suggested People',
peoplePickerNoResults: 'No results found',
peoplePickerLoading: 'Loading users',
peoplePickerMe: 'Me',
taxonomyPickerSuggestionHeader: 'Suggested Terms',
taxonomyPickerNoResults: 'No results found',
taxonomyPickerLoading: 'Loading terms',
datePickerLocale: 'en',
datePickerFormat: 'MMM Do YYYY, hh:mm a',
datePickerExpressionError: 'Expression must respect the following format : [Today] or [Today] +/- [digit]',
datePickerDatePlaceholder: 'Select a date...',
datePickerExpressionPlaceholder: 'Or enter a valid expression...',
datePickerIncludeTime: 'Include time in query',
datePickerStrings: {
months: [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ],
shortMonths: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ],
days: [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ],
shortDays: [ 'S', 'M', 'T', 'W', 'T', 'F', 'S' ],
goToToday: 'Go to today'
}
}
},
viewFieldsChecklistStrings: {
label: 'View Fields',
loading: 'Loading fields from specified list...',
errorFormat: 'An error occured while loading fields : {0}'
},
contentQueryStrings: {
loadingItems: 'Processing query',
errorLoadingQuery: 'An error occured while processing the query : {0}',
errorLoadingTemplate: 'An error occured while loading the template: {0}',
errorProcessingTemplate: 'An error occured while processing the handlebars template : {0}'
}
}
});

View File

@ -0,0 +1,38 @@
declare interface IContentQueryStrings {
SourcePageDescription: string;
QueryPageDescription: string;
DisplayPageDescription: string;
SourceGroupName: string;
QueryGroupName: string;
DisplayGroupName: string;
WebUrlFieldLabel: string;
WebUrlFieldPlaceholder: string;
WebUrlFieldLoadingLabel: string;
WebUrlFieldLoadingError: string;
ListTitleFieldLabel: string;
ListTitleFieldPlaceholder: string;
ListTitleFieldLoadingLabel: string;
ListTitleFieldLoadingError: string;
OrderByFieldLabel: string;
OrderByFieldLoadingLabel: string;
OrderByFieldLoadingError: string;
LimitEnabledFieldLabel: string;
ItemLimitPlaceholder: string;
ErrorItemLimit: string;
TemplateUrlFieldLabel: string;
TemplateUrlPlaceholder: string;
ErrorTemplateExtension: string;
ErrorTemplateResolve: string;
ErrorWebAccessDenied: string;
ErrorWebNotFound: string;
ShowItemsAscending: string;
ShowItemsDescending: string;
queryFilterPanelStrings: any;
viewFieldsChecklistStrings: any;
contentQueryStrings: any;
}
declare module 'contentQueryStrings' {
const strings: IContentQueryStrings;
export = strings;
}

View File

@ -0,0 +1,9 @@
/// <reference types="mocha" />
import { assert } from 'chai';
describe('ContentQueryWebPart', () => {
it('should do something', () => {
assert.ok(true);
});
});

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "commonjs",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"types": [
"es6-promise",
"es6-collections",
"webpack-env",
"microsoft-ajax",
"sharepoint"
]
}
}

8
typings/@ms/odsp.d.ts vendored Normal file
View File

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

1
typings/tsd.d.ts vendored Normal file
View File

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