New sample - SPFx React Textbox.io rich text editor integration (#340)

* Added the react-search-refiners folder

* Updated pagination + format date for DateTime filters

* Added grouped list in the filter panel + fixed some bugs

* Added custom scrollbar style

* Added a placeholder when the Web Part is not configured

* Cleaned code

* Miscellaneous fixes before PR.

* Added Web Part sample images

* Updated images

* react-textboxio - Initial release. Need README

* Updated README

* Added missing image

* Updated README

* Updated the crawl property in Web Part metadata fro search indexing

* Updated the gif image with better quality
This commit is contained in:
Franck Cornu 2017-10-27 11:14:14 -04:00 committed by Vesa Juvonen
parent 2fc7ee1763
commit 89cd64eadb
43 changed files with 1861 additions and 0 deletions

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

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

View File

@ -0,0 +1,8 @@
{
"@microsoft/generator-sharepoint": {
"version": "1.3.2",
"libraryName": "react-search-refiners",
"libraryId": "890affef-33e0-4d72-bd72-36399e02143b",
"environment": "spo"
}
}

View File

@ -0,0 +1,66 @@
# SharePoint Framework search with refiners and paging sample
## Summary
This sample shows you how to build user friendly SharePoint search experiences using Office UI fabric tiles, custom refiners and paging.
<p align="center">
<img src="./images/react-search-refiners.gif"/>
</p>
## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/drop-1.3.0-green.svg)
## Applies to
* [SharePoint Framework](https:/dev.office.com/sharepoint)
* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
## Solution
Solution|Author(s)
--------|---------
react-search-refiners | Franck Cornu (MVP Office Development at aequos) - Twitter @FranckCornu
## Version history
Version|Date|Comments
-------|----|--------
1.0 | October 17, 2017 | Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
- Clone this repository
- In the command line run:
- `npm install`
- `gulp serve`
### Web Part property pane options
The following settings are available in the Web Part property pane:
<p align="center">
<img src="./images/property_pane.png"/>
</p>
Setting | Description
-------|----
Search query | The search query in KQL format. You can use search query variables (See this [post](http://www.techmikael.com/2015/07/sharepoint-rest-do-support-query.html) to know which ones are allowed).
Selected properties | The search managed properties to retrieve. You can use these proeprties then in the code like this (`item.property_name`). (See the *Tile.tsx* file) .
Refiners | The search managed properties to use as refiners. Make sure these are refinable. With SharePoint Online, you have to reuse the default ones to do so (RefinableStringXX etc.). The order is the same as they will appear in the refnement panel.
Number of items to retrieve per page | Quite explicit. The paging behavior is done directly by the search API (See the *SearchDataProvider.ts* file), not by the code on post-render.
Show paging | Indicates whether or not the component should show the paging control at the bottom.
## Features
This Web Part illustrates the following concepts on top of the SharePoint Framework:
- Build an user friendly search experience on the top of the SharePoint search REST API with paging and refiners using the *sp-pnp-js* library.
- Integrate the [@pnp/spfx-property-controls](https://github.com/SharePoint/sp-dev-fx-property-controls) in your solution (*PlaceHolder* control).
- Integrate multiple Office UI Fabric components (DocumentCard, Panel, GroupedList, ...) to fit with the native Office 365 theme.
- Use the React container component approach inspiring by the [react-todo-basic sample](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-todo-basic).
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-search-refiners" />

View File

@ -0,0 +1,19 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"search-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/search/SearchWebPart.js",
"manifest": "./src/webparts/search/SearchWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"SearchWebPartStrings": "lib/webparts/search/loc/{locale}.js",
"PropertyControlStrings": "./node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "react-search-refiners",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,11 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "PnP - Search and Refiners Web Part",
"id": "890affef-33e0-4d72-bd72-36399e02143b",
"version": "1.0.0.1"
},
"paths": {
"zippedPackage": "solution/pnp-react-search-refiners.sppkg"
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://dev.office.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://localhost:5432/workbench",
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

View File

@ -0,0 +1,45 @@
{
"$schema": "https://dev.office.com/json-schemas/core-build/tslint.schema.json",
// 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": false,
"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-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
}
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,6 @@
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.initialize(gulp);

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -0,0 +1,43 @@
{
"name": "react-search-refiners",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "~1.3.0",
"@microsoft/sp-lodash-subset": "~1.3.0",
"@microsoft/sp-webpart-base": "~1.3.0",
"@pnp/spfx-controls-react": "^1.0.0-beta.6",
"@pnp/spfx-property-controls": "1.0.0-beta.2",
"@types/react": "15.0.38",
"@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",
"immutability-helper": "2.4.0",
"lodash-es": "4.17.4",
"moment": "2.18.1",
"office-ui-fabric-react": "4.40.2-hotfix.1",
"react": "15.4.2",
"react-custom-scrollbars": "4.1.2",
"react-dom": "15.4.2",
"react-js-pagination": "3.0.0",
"sp-pnp-js": "3.0.1"
},
"devDependencies": {
"@microsoft/sp-build-web": "~1.3.0",
"@microsoft/sp-module-interfaces": "~1.3.0",
"@microsoft/sp-webpart-workbench": "~1.3.0",
"gulp": "~3.9.1",
"@types/chai": ">=3.4.34 <3.6.0",
"@types/mocha": ">=2.2.33 <2.6.0"
}
}

View File

@ -0,0 +1,17 @@
import { ISearchResults, IRefinementFilter } from "../models/ISearchResult";
interface ISearchDataProvider {
/**
* Determines the number of items ot retrieve in search REST requests
*/
resultsCount: number;
selectedProperties: string[];
/**
* Performs a SharePoint search query
*/
search(kqlQuery: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise<ISearchResults>;
}
export default ISearchDataProvider;

View File

@ -0,0 +1,151 @@
import ISearchDataProvider from "./ISearchDataProvider";
import { ISearchResults, IRefinementFilter, ISearchResult } from "../models/ISearchResult";
import intersection from "lodash-es/intersection";
import clone from "lodash-es/clone";
class MockSearchDataProvider implements ISearchDataProvider {
public selectedProperties: string[];
private _itemsCount: number;
public get resultsCount(): number { return this._itemsCount; }
public set resultsCount(value: number) { this._itemsCount = value; }
private _searchResults: ISearchResults;
public constructor() {
this._searchResults = {
RelevantResults: [
{
Title: "Document 1 - Category 1",
Url: "http://document1.ca",
Created: "2017-07-22T15:38:54.0000000Z",
RefinementTokenValues: "ǂǂ446f63756d656e74,ǂǂ45647563617465",
ContentCategory: "Document",
},
{
Title: "Document 2 - Category 2",
Url: "http://document2.ca",
Created: "2017-07-22T15:38:54.0000000Z",
RefinementTokenValues: "ǂǂ446f63756d656e74,ǂǂ416476697365",
ContentCategory: "Document",
},
{
Title: "Form 1",
Url: "http://form1.ca",
Created: "2017-07-22T15:38:54.0000000Z",
RefinementTokenValues: "ǂǂ466f726d",
ContentCategory: "Form",
},
{
Title: "Video 1 - Category 1",
Url: "https://www.youtube.com/watch?v=S93e6UU7y9o",
Created: "2017-07-22T15:38:54.0000000Z",
RefinementTokenValues: "ǂǂ566964656f,ǂǂ45647563617465",
ContentCategory: "Video",
},
{
Title: "Video 2 - Category 2",
Url: "https://www.youtube.com/watch?v=8Nl_dKVQ1O8",
Created: "2017-07-22T15:38:54.0000000Z",
RefinementTokenValues: "ǂǂ566964656f,ǂǂ416476697365",
ContentCategory: "Video",
},
],
RefinementResults: [
{
FilterName: "Type",
Values: [
{
RefinementCount: 2,
RefinementName: "Document",
RefinementToken: "ǂǂ446f63756d656e74",
RefinementValue: "Document",
},
{
RefinementCount: 2,
RefinementName: "Video",
RefinementToken: "ǂǂ566964656f",
RefinementValue: "Video",
},
{
RefinementCount: 1,
RefinementName: "Form",
RefinementToken: "ǂǂ466f726d",
RefinementValue: "Form",
}
]
},
{
FilterName: "Theme",
Values: [
{
RefinementCount: 2,
RefinementName: "Category 1",
RefinementToken: "ǂǂ45647563617465",
RefinementValue: "Category 1",
},
{
RefinementCount: 2,
RefinementName: "Category 2",
RefinementToken: "ǂǂ416476697365",
RefinementValue: "Category 2",
},
]
}
],
TotalRows: 5,
};
}
public search(query: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise<ISearchResults> {
const p1 = new Promise<ISearchResults>((resolve, reject) => {
const filters: string[] = [];
let searchResults = clone(this._searchResults);
const filteredResults: ISearchResult[] = [];
if (refinementFilters.length > 0) {
refinementFilters.map((filter) => {
filters.push(filter.Value.RefinementToken);
});
searchResults.RelevantResults.map((searchResult) => {
const filtered = intersection(filters, searchResult.RefinementTokenValues.split(","));
if (filtered.length > 0) {
filteredResults.push(searchResult);
}
});
searchResults = {
RelevantResults: filteredResults,
RefinementResults: this._searchResults.RefinementResults,
TotalRows: filteredResults.length,
};
}
// Return only the specified count
searchResults.RelevantResults = this._paginate(searchResults.RelevantResults, this._itemsCount, pageNumber);
// Simulate an async call
setTimeout(() => {
resolve(searchResults);
}, 1000);
});
return p1;
}
private _paginate (array, pageSize: number, pageNumber: number) {
let basePage = --pageNumber * pageSize;
return pageNumber < 0 || pageSize < 1 || basePage >= array.length
? []
: array.slice(basePage, basePage + pageSize );
}
}
export default MockSearchDataProvider;

View File

@ -0,0 +1,277 @@
import ISearchDataProvider from "./ISearchDataProvider";
import { ISearchResults, ISearchResult, IRefinementResult, IRefinementValue, IRefinementFilter } from "../models/ISearchResult";
import pnp, { ConsoleListener, Logger, LogLevel, SearchQuery, SearchQueryBuilder, SearchResults, setup, Web, Sort, SortDirection } from "sp-pnp-js";
import { IWebPartContext } from "@microsoft/sp-webpart-base";
import { Text, JsonUtilities, UrlUtilities } from "@microsoft/sp-core-library";
import sortBy from "lodash-es/sortBy";
import groupBy from 'lodash-es/groupBy';
import mapValues from 'lodash-es/mapValues';
import mapKeys from "lodash-es/mapKeys";
import * as moment from "moment";
class SearchDataProvider implements ISearchDataProvider {
private _resultsCount: number;
private _context: IWebPartContext;
private _appSearchSettings: SearchQuery;
private _selectedProperties: string[];
public get resultsCount(): number { return this._resultsCount; }
public set resultsCount(value: number) { this._resultsCount = value; }
public set selectedProperties(value: string[]) { this._selectedProperties = value; }
public get selectedProperties(): string[] { return this._selectedProperties; }
public constructor(webPartContext: IWebPartContext) {
this._context = webPartContext;
// Setup the PnP JS instance
const consoleListener = new ConsoleListener();
Logger.subscribe(consoleListener);
// To limit the payload size, we set odata=nometadata
// We just need to get list items here
// We also set the SPFx context accordingly (https://github.com/SharePoint/PnP-JS-Core/wiki/Using-sp-pnp-js-in-SharePoint-Framework)
setup({
sp: {
headers: {
Accept: "application/json; odata=nometadata",
},
},
spfxContext: this._context,
});
}
/**
* Performs a search query against SharePoint
* @param query The search query in KQL format
* @return The search results
*/
public async search(query: string, refiners?: string, refinementFilters?: IRefinementFilter[], pageNumber?: number): Promise<ISearchResults> {
let searchQuery: SearchQuery = {};
let sortedRefiners: string[] = [];
// Search paging option is one based
let page = pageNumber ? pageNumber: 1;
searchQuery.ClientType = "ContentSearchRegular";
// To be able to use search query variable according to the current context
// http://www.techmikael.com/2015/07/sharepoint-rest-do-support-query.html
searchQuery.QueryTemplate = query ? query : "";;
searchQuery.RowLimit = this._resultsCount;
searchQuery.SelectProperties = this._selectedProperties;
searchQuery.TrimDuplicates = false;
let sortList: Sort[] = [
{
Property: "Created",
Direction: SortDirection.Descending
},
{
Property: "Size",
Direction: SortDirection.Ascending
}
];
searchQuery.SortList = sortList;
if (refiners) {
// Get the refiners order specified in the property pane
sortedRefiners = refiners.split(",");
searchQuery.Refiners = refiners ? refiners : "";
}
if (refinementFilters) {
if (refinementFilters.length > 0) {
searchQuery.RefinementFilters = [this._buildRefinementQueryString(refinementFilters)];
}
}
let results: ISearchResults = {
RelevantResults : [],
RefinementResults: [],
TotalRows: 0,
};
try {
const r = await pnp.sp.search(searchQuery);
const allItemsPromises: Promise<any>[] = [];
let refinementResults: IRefinementResult[] = [];
// Need to do this check
// More info here: https://github.com/SharePoint/PnP-JS-Core/issues/337
if (r.RawSearchResults.PrimaryQueryResult) {
// Be careful, there was an issue with paging calculation under 2.0.8 version of sp-pnp-js library
// More info https://github.com/SharePoint/PnP-JS-Core/issues/535
const r2 = await r.getPage(page, this._resultsCount);
const resultRows = r2.RawSearchResults.PrimaryQueryResult.RelevantResults.Table.Rows;
let refinementResultsRows = r2.RawSearchResults.PrimaryQueryResult.RefinementResults;
const refinementRows = refinementResultsRows ? refinementResultsRows["Refiners"] : [];
// Map search results
resultRows.map((elt) => {
const p1 = new Promise<ISearchResult>((resolvep1, rejectp1) => {
// Build item result dynamically
// We can't type the response here because search results are by definition too heterogeneous so we treat them as key-value object
let result: ISearchResult = {};
elt.Cells.map((item) => {
result[item.Key] = item.Value;
});
// Get the icon source URL
this._mapToIcon(result.Filename).then((iconUrl) => {
result.iconSrc = iconUrl;
resolvep1(result);
}).catch((error) => {
rejectp1(error);
});
});
allItemsPromises.push(p1);
});
// Map refinement results
refinementRows.map((refiner) => {
let values: IRefinementValue[] = [];
refiner.Entries.map((item) => {
values.push({
RefinementCount: item.RefinementCount,
RefinementName: this._formatDate(item.RefinementName), //This value will appear in the selected filter bar
RefinementToken: item.RefinementToken,
RefinementValue: this._formatDate(item.RefinementValue), // This value will appear in the filter panel
});
});
refinementResults.push({
FilterName: refiner.Name,
Values: values,
});
});
// Resolve all the promises once to get news
const relevantResults: ISearchResult[] = await Promise.all(allItemsPromises);
// Sort refiners according to the property pane value
refinementResults = sortBy(refinementResults, (refinement) => {
// Get the index of the corresponding filter name
return sortedRefiners.indexOf(refinement.FilterName);
});
results.RelevantResults = relevantResults;
results.RefinementResults = refinementResults,
results.TotalRows = r.TotalRows;
}
return results;
} catch (error) {
Logger.write("[SharePointDataProvider.search()]: Error: " + error, LogLevel.Error);
throw error;
}
}
/**
* Gets the icon corresponding to the file name extension
* @param filename The file name (ex: file.pdf)
*/
private async _mapToIcon(filename: string): Promise<string> {
const webAbsoluteUrl = this._context.pageContext.web.absoluteUrl;
const web = new Web(webAbsoluteUrl);
try {
const encodedFileName = filename ? filename.replace(/["']/g, "") : "";
const iconFileName = await web.mapToIcon(encodedFileName,1);
const iconUrl = webAbsoluteUrl + "/_layouts/15/images/" + iconFileName;
return iconUrl;
} catch (error) {
Logger.write("[SharePointDataProvider._mapToIcon()]: Error: " + error, LogLevel.Error);
throw error;
}
}
/**
* Find and eeplace ISO 8601 dates in the string by a friendly value
* @param inputValue The string to format
*/
private _formatDate(inputValue: string): string {
const iso8061rgx = /(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/g;
const matches = inputValue.match(iso8061rgx);
let updatedInputValue = inputValue;
if (matches) {
matches.map(match => {
updatedInputValue = updatedInputValue.replace(match, moment(match).format("LL"));
});
}
return updatedInputValue;
}
/**
* Build the refinement condition in FQL format
* @param selectedFilters The selected filter array
*/
private _buildRefinementQueryString(selectedFilters: IRefinementFilter[]): string {
let refinementQueryConditions: string[] = [];
let refinementQueryString: string = null;
const refinementFilters = mapValues(groupBy(selectedFilters, 'FilterName'), (values) => {
const refinementFilter = values.map((filter) => {
return filter.Value.RefinementToken;
});
return refinementFilter.length > 1 ? "or(" + refinementFilter + ")" : refinementFilter.toString();
});
mapKeys(refinementFilters, (value, key) => {
refinementQueryConditions.push(key + ":" + value);
});
const conditionsCount = refinementQueryConditions.length;
switch (true) {
// No filters
case (conditionsCount === 0): {
refinementQueryString = null;
break;
}
// Just one filter
case (conditionsCount === 1): {
refinementQueryString = refinementQueryConditions[0].toString();
break;
}
// Multiple filters
case (conditionsCount > 1): {
refinementQueryString = "and(" + refinementQueryConditions.toString() + ")";
break;
}
}
return refinementQueryString;
}
}
export default SearchDataProvider;

View File

@ -0,0 +1,27 @@
export interface ISearchResults {
RelevantResults: ISearchResult[];
RefinementResults: IRefinementResult[];
TotalRows?: number;
}
export interface ISearchResult {
[key: string]: string;
IconSrc?: string;
}
export interface IRefinementResult {
FilterName: string;
Values: IRefinementValue[];
}
export interface IRefinementValue {
RefinementCount: number;
RefinementName: string;
RefinementToken: string;
RefinementValue: string;
}
export interface IRefinementFilter {
FilterName: string;
Value: IRefinementValue;
}

View File

@ -0,0 +1,5 @@
import { IRefinementFilter } from "./ISearchResult";
type RefinementFilterOperationCallback = (filters: IRefinementFilter[]) => void;
export default RefinementFilterOperationCallback;

View File

@ -0,0 +1,7 @@
export interface ISearchWebPartProps {
searchQuery: string;
maxResultsCount: number;
selectedProperties: string;
refiners: string;
showPaging: boolean;
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "42ad2740-3c60-49cf-971a-c44e33511b93",
"alias": "SearchWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "PnP" },
"title": { "default": "Search with Refiners" },
"description": { "default": "Displays search results with customizable dynamic refiners" },
"officeFabricIconFontName": "Search",
"properties": {
"searchQuery": "",
"refiners": "",
"selectedProperties": "",
"maxResultsCount": 10
}
}]
}

View File

@ -0,0 +1,152 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
PropertyPaneSlider,
IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneToggle
} from '@microsoft/sp-webpart-base';
import { Environment, EnvironmentType } from '@microsoft/sp-core-library';
import * as strings from 'SearchWebPartStrings';
import SearchContainer from "./components/SearchContainer/SearchContainer";
import ISearchContainerProps from "./components/SearchContainer/ISearchContainerProps";
import { ISearchWebPartProps } from './ISearchWebPartProps';
import ISearchDataProvider from "../dataProviders/ISearchDataProvider";
import MockSearchDataProvider from "../dataProviders/MockSearchDataProvider";
import SearchDataProvider from "../dataProviders/SearchDataProvider";
import * as moment from "moment";
import { Placeholder, IPlaceholderProps } from "@pnp/spfx-controls-react/lib/Placeholder";
export default class SearchWebPart extends BaseClientSideWebPart<ISearchWebPartProps> {
private _dataProvider: ISearchDataProvider;
/**
* Override the base onInit() implementation to get the persisted properties to initialize data provider.
*/
protected onInit(): Promise<void> {
// Init the moment JS library locale globally
const currentLocale = this.context.pageContext.cultureInfo.currentCultureName;
moment.locale(currentLocale);
if (Environment.type === EnvironmentType.Local) {
this._dataProvider = new MockSearchDataProvider();
} else {
this._dataProvider = new SearchDataProvider(this.context);
}
return super.onInit();
}
protected get disableReactivePropertyChanges(): boolean {
return true;
}
public render(): void {
this._dataProvider.resultsCount = this.properties.maxResultsCount;
this._dataProvider.selectedProperties = this.properties.selectedProperties ?
this.properties.selectedProperties.replace(/\s|,+$/g,'').split(",") :[];
let renderElement = null;
const searchContainer: React.ReactElement<ISearchContainerProps> = React.createElement(
SearchContainer,
{
dataProvider: this._dataProvider,
searchQuery: this.properties.searchQuery,
maxResultsCount: this.properties.maxResultsCount,
selectedProperties: this.properties.selectedProperties,
refiners: this.properties.refiners,
showPaging: this.properties.showPaging,
}
);
const placeholder: React.ReactElement<IPlaceholderProps> = React.createElement(
Placeholder,
{
iconName: strings.PlaceHolderEditLabel,
iconText: strings.PlaceHolderIconText,
description: strings.PlaceHolderDescription,
buttonLabel: strings.PlaceHolderConfigureBtnLabel,
onConfigure: this._setupWebPart.bind(this)
}
);
renderElement = this.properties.searchQuery ? searchContainer : placeholder;
ReactDom.render(renderElement, this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
groups: [
{
groupName: strings.SearchSettingsGroupName,
groupFields: [
PropertyPaneTextField('searchQuery', {
label: strings.SearchQueryFieldLabel,
value: "Path:{Site}",
multiline: true,
resizable: true,
placeholder: strings.SearchQueryPlaceHolderText,
onGetErrorMessage: this._validateEmptyField.bind(this)
}),
PropertyPaneTextField('selectedProperties', {
label: strings.SelectedPropertiesFieldLabel,
multiline: true,
resizable: true,
value: "Title,Path,Created,Filename,ServerRedirectedPreviewURL",
}),
PropertyPaneTextField('refiners', {
label: strings.RefinersFieldLabel,
multiline: true,
resizable: true,
value: "Created"
}),
PropertyPaneSlider("maxResultsCount", {
label: strings.MaxResultsCount,
max: 50,
min: 1,
showValue: true,
step: 1,
value: 50,
}),
PropertyPaneToggle("showPaging", {
label: strings.ShowPagingLabel,
checked: false,
}),
]
}
]
}
]
};
}
/**
* Opens the Web Part property pane
*/
private _setupWebPart() {
this.context.propertyPane.open();
}
private _validateEmptyField(value: string): string {
if (!value) {
return strings.EmptyFieldErrorMessage;
}
return "";
}
}

View File

@ -0,0 +1,288 @@
import * as React from "react";
import IFilterPanelProps from "./IFilterPanelProps";
import IFilterPanelState from "./IFilterPanelState";
import { PrimaryButton, DefaultButton, IButtonProps } from 'office-ui-fabric-react/lib/Button';
import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel';
import { Toggle } from 'office-ui-fabric-react/lib/Toggle';
import * as strings from "SearchWebPartStrings";
import { IRefinementResult, IRefinementValue, IRefinementFilter } from "../../../models/ISearchResult";
import { Link } from 'office-ui-fabric-react/lib/Link';
import { Label } from 'office-ui-fabric-react/lib/Label';
import { Text } from "@microsoft/sp-core-library";
import "../SearchWebPart.scss";
import * as update from "immutability-helper";
import {
GroupedList,
IGroup,
IGroupDividerProps
} from 'office-ui-fabric-react/lib/components/GroupedList/index';
import { Scrollbars } from 'react-custom-scrollbars';
export default class FilterPanel extends React.Component<IFilterPanelProps, IFilterPanelState> {
private _initialFilters: IRefinementResult[];
public constructor(props) {
super(props);
// The initialFilters are just set once and never updated afterwards so we don't need to put them in the component state.
// We dont' want the refiners update every time to be able to revert changes easily in the interface and don't lose initial refiners.
this._initialFilters = this.props.availableFilters;
this.state = {
showPanel: false,
selectedFilters: [],
expandedGroups: [],
};
this._onTogglePanel = this._onTogglePanel.bind(this);
this._onClosePanel = this._onClosePanel.bind(this);
this._addFilter = this._addFilter.bind(this);
this._removeFilter = this._removeFilter.bind(this);
this._isInFilterSelection = this._isInFilterSelection.bind(this);
this._applyAllfilters = this._applyAllfilters.bind(this);
this._removeAllFilters = this._removeAllFilters.bind(this);
this._onRenderHeader = this._onRenderHeader.bind(this);
this._onRenderCell = this._onRenderCell.bind(this);
}
public render(): React.ReactElement<IFilterPanelProps> {
let items: JSX.Element[] = [];
let groups: IGroup[] = [];
// Initialize the Office UI grouped list
this._initialFilters.map((filter, i) => {
groups.push({
key: i.toString(),
name: filter.FilterName,
count: 1,
startIndex: i,
isDropEnabled: true,
isCollapsed: this.state.expandedGroups.indexOf(i) === -1 ? true : false,
});
items.push(
<div key= { i }>
<div className="filterPanel__filterProperty">
{
filter.Values.map((refinementValue: IRefinementValue, j) => {
// Create a new IRefinementFilter with only the current refinement information
const currentRefinement: IRefinementFilter = {
FilterName: filter.FilterName,
Value: refinementValue,
};
return (
<Toggle
key={ j }
checked= { this._isInFilterSelection(currentRefinement) }
disabled={ false }
label={ Text.format(refinementValue.RefinementValue + " ({0})", refinementValue.RefinementCount)}
onChanged= {(checked: boolean) => {
// Every time we chek/uncheck a filter, a complete new search request is performed with current selected refiners
checked ? this._addFilter(currentRefinement): this._removeFilter(currentRefinement);
}} />
);
})
}
</div>
</div>
);
});
const renderSelectedFilters: JSX.Element[] = this.state.selectedFilters.map((filter) => {
return (
<PrimaryButton
key ={filter.Value.RefinementToken }
className="searchWp__selectedFilters__filterBtn"
iconProps={ { iconName: 'StatusErrorFull' } }
text={ filter.Value.RefinementName }
onClick={ ()=> { this._removeFilter(filter); }}
/>
);
});
const renderAvailableFilters = <GroupedList
ref='groupedList'
items={ items }
onRenderCell={ this._onRenderCell }
className="filterPanel__body__group"
groupProps={
{
onRenderHeader: this._onRenderHeader,
}
}
groups={ groups }/>;
return (
<div>
<DefaultButton
className="searchWp__filterResultBtn"
iconProps={ { iconName: 'Filter' } }
text={ strings.FilterResultsButtonLabel }
onClick= { this._onTogglePanel }
/>
{ (this.state.selectedFilters.length > 0) ?
<div className="searchWp__selectedFilters">
<Label className="searchWp__selectedFilterLbl ms-font-s">{ strings.SelectedFiltersLabel }</Label>
{ renderSelectedFilters }
</div>
: null
}
<Panel
className="filterPanel"
isOpen={ this.state.showPanel }
type={ PanelType.smallFixedFar }
isBlocking={ false }
isLightDismiss= { true }
onDismiss={ this._onClosePanel }
headerText={ strings.FilterPanelTitle }
closeButtonAriaLabel='Close'
hasCloseButton={ true }
headerClassName="filterPanel__header"
onRenderBody={() => {
if(this._initialFilters.length > 0) {
return (
<Scrollbars style={{ height: "100%" }}>
<div className="filterPanel__body">
<div className="filterPanel__body__allFiltersToggle">
<Toggle
onText={ strings.RemoveAllFiltersLabel }
offText={ strings.ApplyAllFiltersLabel }
onChanged= {(checked: boolean) => {
checked ? this._applyAllfilters() : this._removeAllFilters();
}}
checked= { this.state.selectedFilters.length === 0 ? false : true }
/>
</div>
{ renderAvailableFilters }
</div>
</Scrollbars>
);
} else {
return (
<div className="filterPanel__body">
{ strings.NoFilterConfiguredLabel }
</div>
);
}
}}>
</Panel>
</div>
);
}
private _onRenderCell(nestingDepth: number, item: any, itemIndex: number) {
return (
<div className="ms-Grid-row" data-selection-index={ itemIndex }>
<div className="ms-Grid-col ms-u-sm10 ms-u-md10 ms-u-lg10 ms-smPush1 ms-mdPush1 ms-lgPush1">
{ item }
</div>
</div>
);
}
private _onRenderHeader(props: IGroupDividerProps): JSX.Element {
return (
<div className="ms-Grid-row" onClick={ () => {
// Update the index for expanded groups to be able to keep it open after a re-render
const updatedExpandedGroups =
props.group.isCollapsed ?
update(this.state.expandedGroups, {$push: [props.group.startIndex]}) :
update(this.state.expandedGroups, {$splice: [[this.state.expandedGroups.indexOf(props.group.startIndex), 1]]});
this.setState({
expandedGroups: updatedExpandedGroups,
});
props.onToggleCollapse(props.group);
}}>
<div className="ms-Grid-col ms-u-sm1 ms-u-md1 ms-u-lg1">
<div className="header-icon">
<i className={ props.group.isCollapsed ? "ms-Icon ms-Icon--ChevronDown" : "ms-Icon ms-Icon--ChevronUp"}></i>
</div>
</div>
<div className="ms-Grid-col ms-u-sm10 ms-u-md10 ms-u-lg10">
<div className="ms-font-l">{ props.group.name }</div>
</div>
</div>
);
}
private _onClosePanel() {
this.setState({ showPanel: false });
}
private _onTogglePanel() {
this.setState({ showPanel: !this.state.showPanel });
}
private _addFilter(filterToAdd: IRefinementFilter): void {
// Add the filter to the selected filters collection
let newFilters = update(this.state.selectedFilters, {$push: [filterToAdd]});
this._applyFilters(newFilters);
}
private _removeFilter(filterToRemove: IRefinementFilter): void {
// Remove the filter from the selected filters collection
let newFilters = this.state.selectedFilters.filter((elt) => {
return elt.Value.RefinementToken !== filterToRemove.Value.RefinementToken;
});
this._applyFilters(newFilters);
}
private _applyAllfilters(): void {
let allFilters: IRefinementFilter[] = [];
this._initialFilters.map((filter) => {
filter.Values.map((refinementValue: IRefinementValue, index) => {
allFilters.push({FilterName: filter.FilterName, Value: refinementValue});
});
});
this._applyFilters(allFilters);
}
private _removeAllFilters(): void {
this._applyFilters([]);
}
/**
* Inner method to effectivly apply the refiners by calling back the parent component
* @param selectedFilters The filters to apply
*/
private _applyFilters(selectedFilters: IRefinementFilter[]): void {
// Save the selected filters
this.setState({
selectedFilters: selectedFilters,
});
this.props.onUpdateFilters(selectedFilters);
}
/**
* Checks if the current filter is present in the list of the selected filters
* @param filterToCheck The filter to check
*/
private _isInFilterSelection(filterToCheck: IRefinementFilter): boolean {
let newFilters = this.state.selectedFilters.filter((filter) => {
return filter.Value.RefinementToken === filterToCheck.Value.RefinementToken;
});
return newFilters.length === 0 ? false : true;
}
}

View File

@ -0,0 +1,9 @@
import { IRefinementResult } from "../../../models/ISearchResult";
import RefinementFilterOperationCallback from "../../../models/RefinementValueOperationCallback";
interface IFilterPanelProps {
availableFilters: IRefinementResult[];
onUpdateFilters: RefinementFilterOperationCallback;
}
export default IFilterPanelProps;

View File

@ -0,0 +1,9 @@
import { IRefinementFilter } from "../../../models/ISearchResult";
interface IFilterPanelState {
showPanel?: boolean;
selectedFilters?: IRefinementFilter[];
expandedGroups?: number[];
}
export default IFilterPanelState;

View File

@ -0,0 +1,10 @@
export type PageUpdateCallback = (pageNumber: number) => void;
interface IPagingProps {
totalItems: number;
itemsCountPerPage: number;
onPageUpdate: PageUpdateCallback;
currentPage: number;
}
export default IPagingProps;

View File

@ -0,0 +1,40 @@
import * as React from "react";
import IPagingProps from "./IPagingProps";
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import Pagination from "react-js-pagination";
export default class Paging extends React.Component<IPagingProps, null> {
constructor(props: IPagingProps) {
super(props);
this._onPageUpdate = this._onPageUpdate.bind(this);
}
public render(): React.ReactElement<IPagingProps> {
return(
<div className="searchWp__paginationContainer">
<div className="searchWp__paginationContainer__pagination">
<Pagination
activePage={this.props.currentPage}
firstPageText={<i className="ms-Icon ms-Icon--DoubleChevronLeft" aria-hidden="true"></i>}
lastPageText={<i className="ms-Icon ms-Icon--DoubleChevronRight" aria-hidden="true"></i>}
prevPageText={<i className="ms-Icon ms-Icon--ChevronLeft" aria-hidden="true"></i>}
nextPageText={<i className="ms-Icon ms-Icon--ChevronRight" aria-hidden="true"></i>}
activeLinkClass={ "active" }
itemsCountPerPage={ this.props.itemsCountPerPage }
totalItemsCount={ this.props.totalItems }
pageRangeDisplayed={5}
onChange={this.props.onPageUpdate}
/>
</div>
</div>
);
}
private _onPageUpdate(pageNumber: number): void {
this.props.onPageUpdate(pageNumber);
}
}

View File

@ -0,0 +1,12 @@
import ISearchDataProvider from "../../../dataProviders/ISearchDataProvider";
interface ISearchContainerProps {
dataProvider: ISearchDataProvider;
searchQuery: string;
maxResultsCount: number;
selectedProperties: string[];
refiners: string;
showPaging: boolean;
}
export default ISearchContainerProps;

View File

@ -0,0 +1,12 @@
import { ISearchResults, IRefinementFilter } from "../../../models/ISearchResult";
interface ISearchContainerState {
results?: ISearchResults;
selectedFilters?: IRefinementFilter[];
currentPage?: number;
errorMessage?: string;
hasError?: boolean;
areResultsLoading?: boolean;
}
export default ISearchContainerState;

View File

@ -0,0 +1,154 @@
import * as React from "react";
import ISearchContainerProps from "./ISearchContainerProps";
import ISearchContainerState from "./ISearchContainerState";
import { MessageBar, MessageBarType } from "office-ui-fabric-react/lib/MessageBar";
import { Spinner, SpinnerSize } from "office-ui-fabric-react/lib/Spinner";
import { Logger, LogLevel } from "sp-pnp-js";
import * as strings from "SearchWebPartStrings";
import { ISearchResults, IRefinementFilter } from "../../../models/ISearchResult";
import TilesList from "../TilesList/TilesList";
import "../SearchWebPart.scss";
import FilterPanel from "../FilterPanel/FilterPanel";
import Paging from "../Paging/Paging";
export default class SearchContainer extends React.Component<ISearchContainerProps,ISearchContainerState> {
public constructor(props) {
super(props);
// Set the initial state
this.state = {
results: {
RefinementResults: [],
RelevantResults: []
},
selectedFilters: [],
currentPage: 1,
areResultsLoading: true,
errorMessage: "",
hasError: false,
};
this._onUpdateFilters = this._onUpdateFilters.bind(this);
this._onPageUpdate = this._onPageUpdate.bind(this);
}
public render(): React.ReactElement<ISearchContainerProps> {
const areResultsLoading = this.state.areResultsLoading;
const items = this.state.results;
const hasError = this.state.hasError;
const errorMessage = this.state.errorMessage;
let wpContent: JSX.Element = null;
if (areResultsLoading) {
wpContent = <Spinner size={ SpinnerSize.large } label={ strings.LoadingMessage } />;
} else {
if (hasError) {
wpContent = <MessageBar messageBarType= { MessageBarType.error }>{ errorMessage }</MessageBar>;
} else {
if (items.RelevantResults.length === 0) {
wpContent =
<div>
<FilterPanel availableFilters={ items.RefinementResults } onUpdateFilters={ this._onUpdateFilters }/>
<div className="searchWp__noresult">{ strings.NoResultMessage }</div>
</div>;
} else {
wpContent =
<div>
<FilterPanel availableFilters={ items.RefinementResults } onUpdateFilters={ this._onUpdateFilters }/>
<TilesList items={ items.RelevantResults }/>
{ this.props.showPaging ?
<Paging
totalItems={ items.TotalRows }
itemsCountPerPage={ this.props.maxResultsCount }
onPageUpdate={ this._onPageUpdate }
currentPage={ this.state.currentPage }/>
: null
}
</div>;
}
}
}
return (
<div className="searchWp">
{ wpContent }
</div>
);
}
public componentDidMount() {
// Async calls
this._getSearchResults(this.props.searchQuery, this.props.refiners, this.state.selectedFilters, this.state.currentPage);
}
public componentWillReceiveProps(nextProps: ISearchContainerProps): void {
// Intermediate state to display the spinner before an async query
this.setState({
areResultsLoading: true,
});
// We reset the page number and refinement filters
this._getSearchResults(nextProps.searchQuery, nextProps.refiners, [], 1);
}
private _getSearchResults(searchQuery: string, refiners: string, refinementFilters?: IRefinementFilter[], pageNumber?: number) {
this.props.dataProvider.search(searchQuery, refiners, refinementFilters, pageNumber).then((searchResults: ISearchResults) => {
this.setState({
results: searchResults,
areResultsLoading: false,
});
}).catch((error) => {
Logger.write("[SearchContainer._getSearchResults()]: Error: " + error, LogLevel.Error);
this.setState({
areResultsLoading: false,
results: { RefinementResults: [], RelevantResults: [] },
hasError: true,
errorMessage: error.message,
});
});
}
/**
* Callback function to apply new filters coming from the filter panel child component
* @param newFilters The new filters to apply
*/
private _onUpdateFilters(newFilters: IRefinementFilter[]) {
this._getSearchResults(this.props.searchQuery, this.props.refiners, newFilters, 1);
this.setState({
selectedFilters: newFilters,
currentPage: 1,
});
}
/**
* Callback function update search results according the page number
* @param pageNumber The page mumber to get
*/
private _onPageUpdate(pageNumber: number) {
this._getSearchResults(this.props.searchQuery, this.props.refiners, this.state.selectedFilters, pageNumber);
this.setState({
currentPage: pageNumber,
});
}
}

View File

@ -0,0 +1,146 @@
.searchWp {
&__tile {
float: left;
&__footer {
padding: 8px 16px;
margin-top: 15px;
}
&__footerInfoRight {
position: absolute;
right: 0;
bottom: 0;
padding: 8px 16px;
color: #ffffff;
background-color: #0078d7;
i {
position: relative;
top: 2px;
}
span {
padding: 5px;
}
}
&__iconContainer {
text-align: center;
i {
position: relative;
font-size: 40px;
}
}
}
&__resultCard {
margin: 10px;
}
&__list {
overflow: hidden;
}
&__noresult {
padding:10px;
text-align: center;
}
iframe {
border: 0;
width: 100%;
}
&__filterResultBtn, &__selectedFilterLbl {
margin: 10px;
}
&__selectedFilters {
&__filterBtn {
border-radius: 10px;
margin-bottom: 10px;
margin-left: 10px;
}
}
&__paginationContainer {
text-align: center;
margin-top: 15px;
.searchWp__paginationContainer__pagination {
display: inline-block;
text-align: center;
ul {
display: inline-block;
padding-left: 0;
margin: 20px 0;
border-radius: 4px;
li {
display: inline;
a {
float: left;
padding: 5px 10px;
text-decoration: none;
border-radius: 15px;
i {
font-size: 10px;
}
}
a:visited {
color: inherit;
}
a.active {
background-color: #0078d7;
color: white;
}
}
}
}
}
}
.filterPanel {
&__body {
padding: 20px;
overflow: auto;
.header-icon {
text-align: right;
margin-bottom:8px;
.ms-Icon {
font-size: 16px;
font-weight: 500;
line-height: 1.5;
letter-spacing: 0.5px;
&.ms-Icon--CalculatorSubtract, &.ms-Icon--CalculatorAddition {
font-weight: bold;
}
}
}
&__allFiltersToggle {
margin-bottom: 25px;
}
&__group {
.ms-List-page~.ms-List-page {
margin-top: 15px;
}
}
}
}

View File

@ -0,0 +1,7 @@
import { ISearchResult } from "../../../models/ISearchResult";
interface ITileProps {
item: ISearchResult;
}
export default ITileProps;

View File

@ -0,0 +1,7 @@
import { ISearchResult } from "../../../models/ISearchResult";
interface ITilesListViewProps {
items?: ISearchResult[];
}
export default ITilesListViewProps;

View File

@ -0,0 +1,49 @@
import * as React from "react";
import ITileProps from "./ITileProps";
import {
DocumentCard,
DocumentCardActions,
DocumentCardActivity,
DocumentCardLocation,
DocumentCardPreview,
DocumentCardTitle,
IDocumentCardPreviewProps
} from 'office-ui-fabric-react/lib/DocumentCard';
import { ImageFit } from 'office-ui-fabric-react/lib/Image';
import * as moment from "moment";
import "../SearchWebPart.scss";
const PREVIEW_IMAGE_WIDTH: number = 204;
const PREVIEW_IMAGE_HEIGHT: number = 111;
export default class Tile extends React.Component<ITileProps, null> {
public render() {
const item = this.props.item;
let previewProps: IDocumentCardPreviewProps = {
previewImages: [
{
url: item.ServerRedirectedURL ? item.ServerRedirectedURL : item.Path,
previewImageSrc: item.ServerRedirectedPreviewURL,
iconSrc: item.iconSrc,
imageFit: ImageFit.cover,
height: PREVIEW_IMAGE_HEIGHT,
}
],
};
return (
<DocumentCard onClickHref={ item.ServerRedirectedURL ? item.ServerRedirectedURL : item.Path } className="searchWp__resultCard">
<div className="searchWp__tile__iconContainer" style={{ "height": PREVIEW_IMAGE_HEIGHT }}>
<DocumentCardPreview { ...previewProps } />
</div>
<DocumentCardTitle title={ item.Title } shouldTruncate={ false } />
<div className="searchWp__tile__footer">
<span>{ moment(item.Created).isValid() ? moment(item.Created).format("L"): null }</span>
</div>
</DocumentCard>
);
}
}

View File

@ -0,0 +1,61 @@
import * as React from "react";
import ITilesListViewProps from "./ITilesListViewProps";
import { List } from 'office-ui-fabric-react/lib/List';
import Tile from "./Tile";
import { IRectangle } from "office-ui-fabric-react/lib/Utilities";
import "../SearchWebPart.scss";
const ROWS_PER_PAGE = 3;
const MAX_ROW_HEIGHT = 300;
export default class TilesList extends React.Component<ITilesListViewProps, null> {
private _positions: any;
private _columnCount: number;
private _columnWidth: number;
private _rowHeight: number;
constructor() {
super();
this._positions = {};
this._getItemCountForPage = this._getItemCountForPage.bind(this);
this._getPageHeight = this._getPageHeight.bind(this);
}
public render() {
const items = this.props.items;
return (
<List
items={ items }
getItemCountForPage={ this._getItemCountForPage }
getPageHeight={ this._getPageHeight }
renderedWindowsAhead={ 4 }
className="searchWp__list"
onRenderCell={ (item, index) => (
<div className="searchWp__tile"
style={ {
width: (100 / this._columnCount) + '%',
} }>
<Tile key={ index } item= { item }/>
</div>
)}/>
);
}
private _getItemCountForPage(itemIndex: number, surfaceRect: IRectangle) {
if (itemIndex === 0) {
this._columnCount = Math.ceil(surfaceRect.width / MAX_ROW_HEIGHT);
this._columnWidth = Math.floor(surfaceRect.width / this._columnCount);
this._rowHeight = this._columnWidth;
}
return this._columnCount * ROWS_PER_PAGE;
}
private _getPageHeight(itemIndex: number, surfaceRect: IRectangle) {
return this._rowHeight * ROWS_PER_PAGE;
}
}

View File

@ -0,0 +1,24 @@
define([], function() {
return {
"SearchSettingsGroupName": "Search settings",
"SearchQueryFieldLabel": "Search query",
"SelectedPropertiesFieldLabel": "Selected Properties",
"LoadingMessage": "Results are loading, please wait...",
"MaxResultsCount": "Number of items to retrieve per page",
"NoResultMessage": "There is no items to show",
"RefinersFieldLabel": "Refiners",
"FilterPanelTitle": "Available filters",
"FilterResultsButtonLabel": "Filter results",
"SelectedFiltersLabel": "Selected filters:",
"ApplyAllFiltersLabel": "Apply all filters",
"RemoveAllFiltersLabel": "Remove all filters",
"ShowPagingLabel": "Show paging",
"NoFilterConfiguredLabel": "No filter configured",
"SearchQueryPlaceHolderText": "Search query in KQL format",
"EmptyFieldErrorMessage": "This field cannot be empty",
"PlaceHolderEditLabel": "Edit",
"PlaceHolderConfigureBtnLabel": "Configure",
"PlaceHolderIconText": "Search Results Web Part with Refinements",
"PlaceHolderDescription": "This component displays search results with paging and customizable refinement panel"
}
});

View File

@ -0,0 +1,24 @@
define([], function() {
return {
"SearchSettingsGroupName": "Paramètres de recherche",
"SearchQueryFieldLabel": "Requête de recherche",
"SelectedPropertiesFieldLabel": "Propriétés à récupérer",
"LoadingMessage": "Les résultats sont en cours de chargement, veuillez patienter...",
"MaxResultsCount": "Nombre de résulats à récupérer par page",
"NoResultMessage": "Il n'y a aucun élément à afficher.",
"RefinersFieldLabel": "Filtres",
"FilterPanelTitle": "Filtres disponibles",
"FilterResultsButtonLabel": "Filtrer l'affichage",
"SelectedFiltersLabel": "Filtre(s) appliqué(s):",
"ApplyAllFiltersLabel": "Appliquer tous les filters",
"RemoveAllFiltersLabel": "Supprimer tous les filtres",
"ShowPagingLabel": "Afficher la pagination",
"NoFilterConfiguredLabel": "Aucun filtre configuré",
"SearchQueryPlaceHolderText": "Requête de recherche au format KQL",
"EmptyFieldErrorMessage": "Ce champ ne peut pas être vide",
"PlaceHolderEditLabel": "Éditer",
"PlaceHolderConfigureBtnLabel": "Configurer",
"PlaceHolderIconText": "Web Part de recherche avec affinements",
"PlaceHolderDescription": "Ce composant affiche une liste de résulats de recherche avec pagination et des filtres configurables"
}
});

View File

@ -0,0 +1,27 @@
declare interface ISearchWebPartStrings {
SearchSettingsGroupName: string;
SearchQueryFieldLabel: string;
SelectedPropertiesFieldLabel: string;
LoadingMessage: string;
MaxResultsCount: string;
NoResultMessage: string;
RefinersFieldLabel: string;
FilterPanelTitle: string;
FilterResultsButtonLabel: string;
SelectedFiltersLabel: string;
ApplyAllFiltersLabel: string;
RemoveAllFiltersLabel: string;
ShowPagingLabel: string;
NoFilterConfiguredLabel: string;
SearchQueryPlaceHolderText: string;
EmptyFieldErrorMessage: string;
PlaceHolderEditLabel: string;
PlaceHolderConfigureBtnLabel: string;
PlaceHolderIconText: string;
PlaceHolderDescription: string;
}
declare module 'SearchWebPartStrings' {
const strings: ISearchWebPartStrings;
export = strings;
}

View File

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

View File

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

View File

@ -0,0 +1,11 @@
// 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;
/* Global defintion for SPO builds */
declare const DATACENTER: boolean;

View File

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