Added new react-pages-hierarchy web part

This commit is contained in:
Bo George 2020-04-30 13:46:27 -04:00
parent a11cb52af7
commit 2ac96bf170
51 changed files with 20669 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,12 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.10.0",
"libraryName": "react-pages-hierarchy",
"libraryId": "89758fb6-85e2-4e2b-ac88-4f4e7e5f60cb",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Bo George
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,62 @@
# React Pages Hierarchy
## Summary
This web part allows users to create a faux page hierarchy in their pages library and use it for page to page navigation. It will ask you to create a page parent property on first use which is then used by the web part to either show a breadcrumb of the current pages ancestors or buttons for the pages children.
![Page Navigator](./assets/PagesHierarchy.gif)
## Used SharePoint Framework Version
![1.10.0](https://img.shields.io/badge/version-1.10.0-green.svg)
## Applies to
* [SharePoint Framework](https:/dev.office.com/sharepoint)
* [Office 365 Developer Tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
> Update accordingly as needed.
## Prerequisites
* Office 365 subscription with SharePoint Online
* SharePoint Framework [development environment](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment) set up
## Solution
Solution|Author(s)
--------|---------
react-pages-hierarchy|Bo George ((@bo_george)[https://twitter.com/bo_george])
## Version history
Version|Date|Comments
-------|----|--------
1.0|April 30, 2020|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`
> Include any additional steps as needed.
## Features
This web part isn't anything fancy but it's useful for some scenarios.
* Parent Page Property Creation - if the web part is added to a page and the Parent Page property does not exist the user will be asked to enable (create) it.
* Security - if the user editing the page/web part doesn't have 'Manage' permissions on the Pages library they will not get the enable button, instead a message telling them to get a site owner to do the enabling.
* Two page relationship views depending on the direction you want to show
* Ancestors shows a breadcrumb view (including the current page) up to parent pages until the parent page property is not set.
* Children shows a button view for all pages that have selected the current page as their parent.
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-pages-hierarchy" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"pagehierarchy-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/pagehierarchy/PageHierarchyWebPart.js",
"manifest": "./src/webparts/pagehierarchy/PageHierarchyWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"PageHierarchyWebPartStrings": "lib/webparts/pagehierarchy/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
}
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "react-pages-hierarchy",
"id": "89758fb6-85e2-4e2b-ac88-4f4e7e5f60cb",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false
},
"paths": {
"zippedPackage": "solution/react-pages-hierarchy.sppkg"
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://developer.microsoft.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,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,27 @@
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
const path = require('path');
build.configureWebpack.mergeConfig({
additionalConfiguration: (generatedConfiguration) => {
if (!generatedConfiguration.resolve.alias) {
generatedConfiguration.resolve.alias = {};
}
// root src folder
generatedConfiguration.resolve.alias['@src'] = path.resolve(__dirname, 'lib')
return generatedConfiguration;
}
});
build.initialize(require('gulp'));
var runSequence = require('run-sequence');
gulp.task('package', function (cb) {
runSequence('clean', 'build', 'bundle', 'package-solution', cb);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
{
"name": "react-pages-hierarchy",
"version": "1.0.0",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.10.0",
"@microsoft/sp-lodash-subset": "1.10.0",
"@microsoft/sp-office-ui-fabric-core": "1.10.0",
"@microsoft/sp-property-pane": "1.10.0",
"@microsoft/sp-webpart-base": "1.10.0",
"@pnp/common": "^2.0.3",
"@pnp/logging": "^2.0.3",
"@pnp/odata": "^2.0.3",
"@pnp/sp": "^2.0.3",
"@pnp/spfx-controls-react": "1.17.0",
"@pnp/spfx-property-controls": "1.17.0",
"@types/es6-promise": "0.0.33",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1",
"office-ui-fabric-react": "6.189.2",
"react": "16.8.5",
"react-dom": "16.8.5",
"react-resize-detector": "^4.2.1"
},
"resolutions": {
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/rush-stack-compiler-3.3": "0.3.5",
"@microsoft/sp-build-web": "1.10.0",
"@microsoft/sp-module-interfaces": "1.10.0",
"@microsoft/sp-tslint-rules": "1.10.0",
"@microsoft/sp-webpart-workbench": "1.10.0",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2",
"gulp": "^3.9.1",
"run-sequence": "^2.2.1"
}
}

View File

@ -0,0 +1,3 @@
export interface Action {
type: string;
}

View File

@ -0,0 +1,5 @@
export interface GetRequest {
isLoading: boolean;
hasError: boolean;
errorMessage: string;
}

View File

@ -0,0 +1,268 @@
import { useReducer, useEffect, useState } from 'react';
import { sp, PermissionKind } from '@pnp/sp/presets/all';
import { ErrorHelper, LogHelper, ListTitles, PageFields } from '@src/utilities';
import { Action } from "./action";
import { GetRequest } from './getRequest';
import { IPage } from '@src/models/IPage';
// state that we track
interface PagesState {
parentPageColumnExists: boolean;
userCanManagePages: boolean;
ancestorPages: IPage[];
childrenPages: IPage[];
getRequest: GetRequest;
}
// api accessable to those outside
interface PageApi {
state: PagesState;
addParentPageField: () => void;
}
/*
* Setup actions, action types and our reducer
*/
interface PageTreePayloadAction extends Action {
payload: {
childgrenPages: IPage[],
ancestorPages: IPage[]
};
}
interface ParentPageColumnExistAction extends Action {
payload: boolean;
}
interface CanUserManagePagesAction extends Action {
payload: boolean;
}
class ActionTypes {
public static readonly CAN_USER_MANAGE_PAGES: string = "CAN_USER_MANAGE_PAGES";
public static readonly PARENT_PAGE_COLUMN_EXISTS: string = "PARENT_PAGE_COLUMN_EXISTS";
public static readonly GET_PAGES: string = "GET_PAGES";
public static readonly GET_PAGES_LOADING: string = "GET_PAGES_LOADING";
public static readonly GET_PAGES_ERRORED: string = "GET_PAGES_ERRORED";
}
function pagesReducer(state: PagesState, action: Action): PagesState {
//First establish the type of the payload
switch (action.type) {
case ActionTypes.GET_PAGES_LOADING:
return { ...state, getRequest: { isLoading: true, hasError: false, errorMessage: "" } };
case ActionTypes.GET_PAGES:
var arrayAction: PageTreePayloadAction = action as PageTreePayloadAction;
return {
...state,
childrenPages: arrayAction.payload.childgrenPages,
ancestorPages: arrayAction.payload.ancestorPages,
getRequest: { isLoading: false, hasError: false, errorMessage: "" }
};
case ActionTypes.GET_PAGES_ERRORED:
return {
...state,
getRequest: {
isLoading: false,
hasError: true,
errorMessage: "Get Pages Failure"
}
};
case ActionTypes.PARENT_PAGE_COLUMN_EXISTS:
var parentPageColumnExistAction: ParentPageColumnExistAction = action as ParentPageColumnExistAction;
return {
...state,
parentPageColumnExists: parentPageColumnExistAction.payload
};
case ActionTypes.CAN_USER_MANAGE_PAGES:
var canUserManagePagesAction: CanUserManagePagesAction = action as CanUserManagePagesAction;
return {
...state,
userCanManagePages: canUserManagePagesAction.payload
};
default:
throw new Error();
}
}
export function usePageApi(currentPageId: number, pageEditFinished: boolean): PageApi {
const [pagesState, pagesDispatch] = useReducer(pagesReducer, {
parentPageColumnExists: true,
userCanManagePages: false,
ancestorPages: [] = [],
childrenPages: [] = [],
getRequest: { isLoading: false, hasError: false, errorMessage: "" },
});
// currentPageId is a dependency only because it can change when on the workbench, otherwise it really wouldn't change while on a page
useEffect(() => {
LogHelper.verbose('usePageApi', 'useEffect', `[currentPageId, ${currentPageId}, pageEditFinished: ${pageEditFinished} ]`);
if (currentPageId) {
checkIfParentPageExists();
getPagesAsync();
}
}, [currentPageId, pageEditFinished]);
async function getPagesAsync() {
LogHelper.verbose('usePageApi', 'getPagesAsync', ``);
// check local storage first and return these and then refresh it in the background
// dispatch the LOADING action
pagesDispatch({ type: ActionTypes.GET_PAGES_LOADING });
// add select and order by later. Order by ID?
let pages: IPage[] = [];
let items = await sp.web.lists.getByTitle(ListTitles.SITEPAGES).items
.select(
PageFields.ID,
PageFields.TITLE,
PageFields.FILEREF,
PageFields.PARENTPAGELOOKUP,
PageFields.PARENTPAGELOOKUP_ID,
PageFields.PARENTPAGELOOKUP_TITLE,
)
.expand(
PageFields.PARENTPAGELOOKUP
)
.top(5000)
.orderBy(PageFields.TITLE, true)
.get()
.catch(e => {
ErrorHelper.handleHttpError('getPages', e);
pagesDispatch({ type: ActionTypes.GET_PAGES_ERRORED });
});
if (items) {
for (let item of items) {
pages.push(mapPage(item));
}
}
const ancestorPages: IPage[] = buildPageAncestors(pages, currentPageId).reverse();
const childrenPages: IPage[] = buildPageChildren(pages, currentPageId);
// dispatch the GET_ALL action
pagesDispatch({
type: ActionTypes.GET_PAGES,
payload: { childgrenPages: childrenPages, ancestorPages: ancestorPages },
} as PageTreePayloadAction);
}
async function checkIfParentPageExists() {
LogHelper.verbose('usePageApi', 'parentPageExists', ``);
let parentPage = await sp.web.lists.getByTitle(ListTitles.SITEPAGES).fields
.getByInternalNameOrTitle(PageFields.PARENTPAGELOOKUP)
.get()
.catch(e => {
// swallow the exception we'll handle below
});
if (parentPage) {
// dispatch the action
pagesDispatch({ type: ActionTypes.PARENT_PAGE_COLUMN_EXISTS, payload: true } as ParentPageColumnExistAction);
}
else {
canCurrentUserManageSitePages();
// dispatch the action
pagesDispatch({ type: ActionTypes.PARENT_PAGE_COLUMN_EXISTS, payload: false } as ParentPageColumnExistAction);
}
}
async function canCurrentUserManageSitePages(): Promise<void> {
let canManagePages = await sp.web.lists.getByTitle(ListTitles.SITEPAGES)
.currentUserHasPermissions(PermissionKind.ManageLists)
.catch(e => {
ErrorHelper.handleHttpError('canUserUpdateSitePages', e);
return false;
});
// dispatch the action
pagesDispatch({ type: ActionTypes.CAN_USER_MANAGE_PAGES, payload: canManagePages } as CanUserManagePagesAction);
}
async function addParentPageFieldToSitePages(): Promise<void> {
LogHelper.verbose('usePageApi', 'addParentPageFieldToSitePages', ``);
let list = await sp.web.lists.getByTitle(ListTitles.SITEPAGES)
.get();
let lookup = await sp.web.lists.getByTitle(ListTitles.SITEPAGES).fields
.addLookup(PageFields.PARENTPAGELOOKUP, list.Id, PageFields.TITLE)
.catch(e => {
return null;
ErrorHelper.handleHttpError('canUserUpdateSitePages', e);
});
await sp.web.lists.getByTitle(ListTitles.SITEPAGES).fields
.getByInternalNameOrTitle(PageFields.PARENTPAGELOOKUP)
.update({ Title: PageFields.PARENTPAGELOOKUP_DISPLAYNAME })
.catch(e => {
ErrorHelper.handleHttpError('canUserUpdateSitePages', e);
});
if (lookup) {
// dispatch the action
pagesDispatch({ type: ActionTypes.PARENT_PAGE_COLUMN_EXISTS, payload: true } as ParentPageColumnExistAction);
}
}
// map a SharePoint List Item to an IPage
function mapPage(item: any): IPage {
let page: IPage = {
id: item.ID,
title: item.Title,
etag: item['odata.etag'] ? item['odata.etag'] : new Date().toISOString(),
url: item[PageFields.FILEREF],
parentPageId: item[PageFields.PARENTPAGELOOKUP] ? item[PageFields.PARENTPAGELOOKUP].ID : null
};
return page;
}
function buildPageAncestors(allPages: IPage[], pageId: number, ancestorPages: IPage[] = []): IPage[] {
// get all ancestor pages
if (pageId) {
const currentPage = allPages.find(f => f.id === pageId);
if (currentPage) {
ancestorPages.push(currentPage);
buildPageAncestors(allPages, currentPage.parentPageId, ancestorPages);
}
}
return ancestorPages;
}
// get all children page
function buildPageChildren(allPages: IPage[], pageId: number): IPage[] {
let childPages: IPage[] = [];
if (pageId) {
childPages = allPages.filter(f => f.parentPageId === pageId);
}
return childPages;
}
const addParentPageField = () => {
addParentPageFieldToSitePages();
};
return {
state: {
parentPageColumnExists: pagesState.parentPageColumnExists,
userCanManagePages: pagesState.userCanManagePages,
ancestorPages: pagesState.ancestorPages,
childrenPages: pagesState.childrenPages,
getRequest: {
isLoading: pagesState.getRequest.isLoading,
hasError: pagesState.getRequest.hasError,
errorMessage: pagesState.getRequest.errorMessage
},
},
addParentPageField
};
}

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,29 @@
import { IFetchOptions, FetchClient } from '@pnp/common';
import { MockResponse } from './mockresponse';
import { MockListFactory } from './mocklistfactory';
import { LogHelper } from '@src/utilities';
export class CustomFetchClient extends FetchClient {
private mockListFactory: MockListFactory = new MockListFactory();
private isUsingSharePoint: boolean;
constructor(isUsingSharePoint: boolean) {
super();
this.isUsingSharePoint = isUsingSharePoint;
}
public fetch(url: string, options: IFetchOptions): Promise<Response> {
LogHelper.verbose(this.constructor.name, 'fetch', url);
if (this.isUsingSharePoint === false) {
url = url.replace('/#/', '/'); // deal with HashLocationStrategy when on local host
return new MockResponse(this.mockListFactory).fetch(url, options);
}
else {
return super.fetch(url, options);
}
}
}

View File

@ -0,0 +1,134 @@
export class Predicate {
public property: string[] = [];
public operator: string;
public value: string;
}
// The code below was initiated using code from the link below and then customized
// https://github.com/jadrake75/odata-filter-parser/blob/master/src/odata-parser.js
// For testing the regex https://www.regexpal.com/
export class FilterParser {
public static Operators = {
EQUALS: 'eq',
AND: 'and',
OR: 'or',
GREATER_THAN: 'gt',
GREATER_THAN_EQUAL: 'ge',
LESS_THAN: 'lt',
LESS_THAN_EQUAL: 'le',
LIKE: 'like',
IS_NULL: 'is null',
NOT_EQUAL: 'ne',
SUBSTRINGOF: 'substringof'
};
/*
Examples documented when we know that we are using them. Any Regex items defined below without an example may not be explicitly tested
lookupop - Lookup/ID eq 1
op - ID eq 1
*/
private REGEX = {
parenthesis: /^([(](.*)[)])$/,
andor: /^(.*?) (or|and)+ (.*)$/,
lookupop: /(\w*\/\w*) (eq|gt|lt|ge|le|ne) (datetimeoffset'(.*)'|'(.*)'|[0-9]*)/,
op: /(\w*) (eq|gt|lt|ge|le|ne) (datetimeoffset'(.*)'|'(.*)'|[0-9]*)/,
startsWith: /^startswith[(](.*),'(.*)'[)]/,
endsWith: /^endswith[(](.*),'(.*)'[)]/,
contains: /^contains[(](.*),'(.*)'[)]/,
substringof: /^substringof[(](.*),(.*)[)]/
};
public parse(filterString: string): any {
// LoggingService.getLogger(this.constructor.name).info(`parse - filter=[${filterString}]`);
if (!filterString || filterString === '') {
return null;
}
let filter = filterString.trim();
let obj = {};
if (filter.length > 0) {
obj = this.parseFragment(filter);
}
return obj;
}
private parseFragment(filter): any {
// LoggingService.getLogger(this.constructor.name).info(`parseFragment - filter=[${filter}]`);
let found: boolean = false;
let obj: Predicate = new Predicate();
// tslint:disable-next-line:forin
for (let key in this.REGEX) {
let regex = this.REGEX[key];
if (found) {
break;
}
let match = filter.match(regex);
if (match) {
switch (regex) {
case this.REGEX.parenthesis:
if (match.length > 2) {
if (match[2].indexOf(')') < match[2].indexOf('(')) {
continue;
}
obj = this.parseFragment(match[2]);
}
break;
case this.REGEX.andor:
obj = {
property: this.parseFragment(match[1]),
operator: match[2],
value: this.parseFragment(match[3])
};
break;
case this.REGEX.lookupop:
case this.REGEX.op:
let property = match[1].split('/');
obj = {
property: property,
operator: match[2],
value: (match[3].indexOf('\'') === -1) ? +match[3] : match[3]
};
if (typeof obj.value === 'string') {
let quoted = obj.value.match(/^'(.*)'$/);
let m = obj.value.match(/^datetimeoffset'(.*)'$/);
if (quoted && quoted.length > 1) {
obj.value = quoted[1];
} else if (m && m.length > 1) {
obj.value = new Date(m[1]).toISOString();
}
}
break;
case this.REGEX.startsWith:
case this.REGEX.endsWith:
case this.REGEX.contains:
obj = this.buildLike(match, key);
break;
case this.REGEX.substringof:
obj = this.buildSubstringof(match, key);
break;
}
found = true;
}
}
return obj;
}
private buildSubstringof(match, key): Predicate {
return {
property: match[2].trim().split('/'),
operator: FilterParser.Operators.SUBSTRINGOF,
value: match[1].trim().split(`'`).join('')
};
}
private buildLike(match, key): Predicate {
let right = (key === 'startsWith') ? match[2] + '*' : (key === 'endsWith') ? '*' + match[2] : '*' + match[2] + '*';
return {
property: match[1],
operator: FilterParser.Operators.LIKE,
value: right
};
}
}

View File

@ -0,0 +1,2 @@
export * from './pagesList';
export * from './userInformationList';

View File

@ -0,0 +1,71 @@
import { ListTitles, PageFields } from '@src/utilities';
import { BaseList } from '../mocklistfactory';
export class PagesList implements BaseList {
public listTitle = ListTitles.SITEPAGES;
public lookups = [
{ itemProperty: PageFields.PARENTPAGELOOKUP, itemKey: PageFields.PARENTPAGELOOKUPID, lookupListTitle: ListTitles.SITEPAGES },
];
public items: any[] = [
{
ID: 1,
Title: 'Earth',
FileRef: '?DebugPageId=1',
ParentPageId: null
},
{
ID: 2,
Title: 'North America',
FileRef: '?DebugPageId=2',
ParentPageId: 1
},
{
ID: 3,
Title: 'United States',
FileRef: '?DebugPageId=3',
ParentPageId: 2
},
{
ID: 4,
Title: 'Southeast',
FileRef: '?DebugPageId=4',
ParentPageId: 3
},
{
ID: 5,
Title: 'Georgia',
FileRef: '?DebugPageId=5',
ParentPageId: 4
},
{
ID: 6,
Title: 'Atlanta',
FileRef: '?DebugPageId=6',
ParentPageId: 5
},
{
ID: 7,
Title: 'Savannah',
FileRef: '?DebugPageId=7',
ParentPageId: 5
},
{
ID: 8,
Title: 'Columbus',
FileRef: '?DebugPageId=8',
ParentPageId: 5
},
{
ID: 9,
Title: 'Alpharetta',
FileRef: '?DebugPageId=9',
ParentPageId: 5
},
{
ID: 10,
Title: 'Macon',
FileRef: '?DebugPageId=10',
ParentPageId: 5
},
];
}

View File

@ -0,0 +1,61 @@
import { ListTitles } from '@src/utilities';
import { BaseList } from '../mocklistfactory';
/*The first user in this will be used as the 'current user'
Notes about the data
Only the first item has a Title property as it only seems that Pnps wrapper on /_api/web/currentuser sends this back
The call to /_api/sp.utilities.utility.searchprincipalsusingcontextweb returns a PrincipalInfo object (PnP object) which has DisplayName rather than Title
*/
export class UsersInformationList implements BaseList {
public listTitle = ListTitles.USERS_INFORMATION;
public items: any[] = [
{
ID: 1,
DisplayName: 'Demo Designer',
Title: 'Demo Designer',
LoginName: 'i:0#.w|domain\\designer',
Key: 'i:0#.w|domain\\designer',
Name: 'i:0#.w|domain\\designer',
JobTitle: 'Demo Designer Title',
Email: 'designer@demo.com',
Mobile: '404-555-1211',
IsSiteAdmin: false,
Groups: {
results: [
]
}
},
{
ID: 2,
DisplayName: 'Demo User',
Title: 'Demo User',
LoginName: 'i:0#.w|domain\\user',
Key: 'i:0#.w|domain\\user',
Name: 'i:0#.w|domain\\user',
JobTitle: 'Demo User Title',
Email: 'user@demo.com',
Mobile: '404-555-1212'
},
{
ID: 3,
DisplayName: 'Demo External User',
Title: 'Demo External User',
LoginName: 'i:0#.w|domain\\externaluser',
Key: 'i:0#.w|domain\\externaluser',
Name: 'i:0#.w|domain\\externaluser',
JobTitle: 'Demo External User Title',
Email: 'externaluser@demo.com',
Mobile: '404-555-1213'
},
{
ID: 4,
DisplayName: 'Demo No Email',
Title: 'Demo No Email',
LoginName: 'i:0#.w|domain\\demo01',
Key: 'i:0#.w|domain\\demo01',
Name: 'i:0#.w|domain\\demo01',
JobTitle: 'Demo No Email Title',
Mobile: '404-555-1215'
}
];
}

View File

@ -0,0 +1,120 @@
import { PagesList, UsersInformationList } from './lists';
import { LogHelper } from '@src/utilities';
export class MockListFactory {
private listMap: BaseList[] = [
new PagesList(),
new UsersInformationList()
];
public getListItems(listTitle: string): any[] {
listTitle = decodeURI(listTitle);
LogHelper.verbose(this.constructor.name, 'getListItems', listTitle);
let items = this.getItemsForMockList(listTitle);
let list = this.listMap.filter(l => l.listTitle === listTitle)[0];
if (list) {
items = this.getStoredItems(list.listTitle, list.items);
}
else {
LogHelper.error(this.constructor.name, 'getListItems', `List factory not found: [${listTitle}]`);
}
if (list && list.lookups !== undefined) {
for (let lookup of list.lookups) {
let lookupListItems = this.getItemsForMockList(lookup.lookupListTitle);
for (let item of items) {
if (lookup.isMulti !== true) {
item[lookup.itemProperty] = this.getLookup(item, lookup.itemKey, lookupListItems);
}
else {
item[lookup.itemProperty] = this.getMultiLookup(item, lookup.itemKey, lookupListItems);
}
}
}
}
return items;
}
public saveListItems(listTitle: string, items: any[]): void {
LogHelper.verbose(this.constructor.name, 'saveListItems', listTitle);
let storageKey = listTitle.split(' ').join('');
this.storeItems(storageKey, items);
}
private getItemsForMockList(listTitle: string): any[] {
let items: any[] = [];
let list = this.listMap.filter(l => l.listTitle === listTitle)[0];
if (list != null) {
items = this.getStoredItems(list.listTitle, list.items);
}
return items;
}
private getStoredItems(listTitle: string, defaultItems: any[]): any[] {
let storageKey = listTitle.split(' ').join('');
let items: any[] = [];
let storedData: string | null;
storedData = localStorage.getItem(storageKey);
if (storedData !== null) {
items = JSON.parse(storedData);
}
else {
items = defaultItems;
this.storeItems(storageKey, items);
}
return items;
}
private storeItems(storageKey: string, items: any[]): void {
let storedData = JSON.stringify(items);
localStorage.setItem(storageKey, storedData);
}
private getLookup(item: any, lookupIdProperty: string, lookupListItems: any[]): any {
if (item[lookupIdProperty] !== undefined) {
return lookupListItems.filter(i => i.ID === item[lookupIdProperty])[0];
}
else {
return null;
}
}
private getMultiLookup(item: any, lookupIdProperty: string, lookupListItems: any[]): any {
if (item[lookupIdProperty] !== undefined && item[lookupIdProperty].results !== undefined && item[lookupIdProperty].results instanceof Array) {
let results: any[] = [];
for (let id of item[lookupIdProperty].results) {
let lookupItem = lookupListItems.filter(i => i.ID === id)[0];
if (lookupItem) {
results.push(lookupItem);
}
}
return { results: results };
}
else {
return { results: [] };
}
}
}
export interface BaseList {
listTitle: string;
items: any[];
lookups?: Lookup[];
}
export class Lookup {
public itemKey: string;
public itemProperty: string;
public lookupListTitle: string;
public isMulti?: boolean;
}

View File

@ -0,0 +1,962 @@
import { IItemUpdateResult, IContextInfo } from '@pnp/sp/presets/all';
import { IFetchOptions } from '@pnp/common';
import { FilterParser } from './filterParser';
import { MockListFactory } from './mocklistfactory';
import { parse } from 'url';
// import * as FileSaver from 'file-saver';
import { LogHelper, ListTitles } from '@src/utilities';
export class MockResponse {
private listTitle: string;
private currentUser;
constructor(private mockListFactory: MockListFactory) { }
public async fetch(url: string, options: IFetchOptions): Promise<Response> {
let response;
this.listTitle = this.getListTitleFromUrl(url);
this.currentUser = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION)[0];
if (options.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('_api/web/currentuser') !== -1) {
response = this.getCurrentUser(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/_api/web/siteusers(@v)?') !== -1) {
response = this.getSiteUser(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/_api/web/siteusers/getbyid') !== -1) {
response = this.getSiteUserById(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/_api/web/siteusers/getbyemail') !== -1) {
response = this.getSiteUserByEmail(url);
}
else if (options!.method!.toUpperCase() === 'GET' && this.endsWith(url, '/_api/web')) {
response = this.getWeb(url);
}
else if (options!.method!.toUpperCase() === 'GET' && this.endsWith(url, '/attachmentfiles')) {
response = this.getAttachments(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/_api/web/getfilebyserverrelativeurl') !== -1) {
response = await this.getFileByServerRelativeUrl(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/items') === -1) {
response = this.getListProperties(url);
}
else if (options!.method!.toUpperCase() === 'GET') {
response = this.getListItems(url);
}
else if (options!.method!.toUpperCase() === 'POST' && this.endsWith(url, '_api/contextinfo')) {
response = this.getContextInfo();
}
else if (options!.method!.toUpperCase() === 'POST' && this.endsWith(url, '_api/$batch')) {
response = await this.processBatch(url, options);
}
else if (options!.method!.toLocaleUpperCase() === 'POST' && this.endsWith(url, '/_api/sp.utilities.utility.searchprincipalsusingcontextweb')) {
response = this.searchPrincipals(url, options);
}
else if (options!.method!.toLocaleUpperCase() === 'POST' && this.endsWith(url, '/_api/sp.ui.applicationpages.clientpeoplepickerwebserviceinterface.clientpeoplepickersearchuser')) {
response = this.clientPeoplePickerSearchUser(url, options);
}
else if (options!.method!.toLocaleUpperCase() === 'POST' && this.endsWith(url, '/_api/sp.utilities.utility.sendemail')) {
response = this.sendEmail(url, options);
}
else if (options!.method!.toUpperCase() === 'POST' && this.endsWith(url, '_api/web/ensureuser')) {
response = this.ensureUser(url, options);
}
else if (options!.method!.toUpperCase() === 'POST' && url.toLowerCase().indexOf('/attachmentfiles') !== -1) {
// add, updates and deletes
response = this.saveAttachmentChanges(url, options);
}
else if (options!.method!.toUpperCase() === 'POST' && url.toLowerCase().indexOf('_api/web/sitegroups/') !== -1) {
response = new Response('', { status: 200 });
}
else if (options!.method!.toUpperCase() === 'POST' && this.endsWith(url, '/getitems')) {
response = this.getListItemsCamlQuery(url, options);
}
else if (options!.method!.toUpperCase() === 'POST' && url.toLowerCase().indexOf('/files/add') !== -1) {
// add, updates and deletes
response = this.saveFile(url, options);
}
else {
// add, updates and deletes
response = this.saveListItemChanges(url, options);
}
return new Promise<Response>((resolve) => {
setTimeout(() => resolve(response), 200);
});
}
private endsWith(url: string, search: string): boolean {
return url.substring(url.length - search.length, url.length) === search;
}
private getListItems(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getListItems', urlString);
let url = parse(urlString, true, true);
let body: string | undefined;
let totalItemsCount: number = 0;
// try to get the data from local storage and then from mock data
let items = this.mockListFactory.getListItems(this.listTitle);
// apply select, filter, top, etc...
items = this.applyFilter(items, url.query.$filter);
items = this.applySelect(items, url.query.$select);
items = this.applyOrderBy(items, url.query.$orderby);
items = this.applySkip(items, url.query.$skip);
totalItemsCount = items.length;
items = this.applyTop(items, url.query.$top);
if (url.pathname.endsWith('/items')) {
body = JSON.stringify(items);
// revisit to figure out how the source is telling us to page
if (items.length < totalItemsCount) {
let skipParts = urlString.split('&$skip=');
let nextUrl = '';
if (skipParts.length === 1) {
nextUrl = `${urlString}"&$skip=${items.length}`;
}
else if (skipParts.length === 2) {
nextUrl = `${urlString}"&$skip=${+skipParts[1] + items.length}`;
}
let result = {
'd': {
'results': items,
'__next': nextUrl
}
};
body = JSON.stringify(result);
}
}
else if (url.pathname.endsWith(')')) {
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 1);
let item = items.filter(i => i.ID === +id)[0];
body = JSON.stringify(item);
}
else {
// not sure what might hit here yet
}
return new Response(body, { status: 200 });
}
private getListProperties(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getListProperties', urlString);
let body = {
'RootFolder': {
'ServerRelativeUrl': `/${this.listTitle}`
},
'ParentWeb': {
'Url': `${window.location.origin}/`
},
'ParentWebUrl': '/',
'Title': this.listTitle
};
return new Response(JSON.stringify(body), { status: 200 });
}
private getListItemsCamlQuery(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'getListItemsCamlQuery', urlString);
let url = parse(urlString, true, true);
let body: string | undefined;
// try to get the data from local storage and then from mock data
let items = this.mockListFactory.getListItems(this.listTitle);
// tslint:disable-next-line:max-line-length
// {"query":{"__metadata":{"type":"SP.CamlQuery"},"ViewXml":"<View><ViewFields><FieldRef Name='Group1'/><FieldRef Name='ProductGroup'/>...</ViewFields><Query><Where><Eq><FieldRef Name='AppliesTo'/><Value Type='Choice'>Cost</Value></Eq></Where></Query><RowLimit>1000</RowLimit></View>"}}
let camlQuery = JSON.parse(options.body);
let viewXml: string = camlQuery.query.ViewXml;
let viewFieldsStart = viewXml.indexOf('<ViewFields>') + 12;
let viewFieldsEnd = viewXml.indexOf('</ViewFields>');
let queryStart = viewXml.indexOf('<Query>') + 7;
let queryEnd = viewXml.indexOf('</Query>');
let rowLimitStart = viewXml.indexOf('<RowLimit>') + 10;
let rowLimitEnd = viewXml.indexOf('</RowLimit>');
let viewFields = viewXml.substring(viewFieldsStart, viewFieldsEnd);
let query = viewXml.substring(queryStart, queryEnd); // <Where><Eq><FieldRef Name='AppliesTo'/><Value Type='Choice'>Cost</Value></Eq></Where>
let rowLimit = viewXml.substring(rowLimitStart, rowLimitEnd);
let select = viewFields.split(`<FieldRef Name='`).join('').split(`'/>`).join(',');
// WARNING - currently this assumes only one clause with an Eq
let whereStart = query.indexOf('<Where>') + 7;
let whereEnd = query.indexOf('</Where>');
let where = query.substring(whereStart, whereEnd); // <Eq><FieldRef Name='AppliesTo'/><Value Type='Choice'>Cost</Value></Eq>
let compare = where.startsWith('<Eq>') ? 'eq' : null; // add other checks for future compares
where = where.split('<Eq>').join('').split('</Eq>').join(''); // <FieldRef Name='AppliesTo'/><Value Type='Choice'>Cost</Value>
let filter = where.split(`<FieldRef Name='`).join('').split(`'/>`).join(` ${compare} `)
.split(`<Value Type='Choice'>`).join(`'`).split('</Value>').join(`'`);
items = this.applyFilter(items, filter);
items = this.applySelect(items, select);
items = this.applyTop(items, rowLimit);
if (url.pathname.endsWith('/getitems')) {
body = JSON.stringify(items);
}
else if (url.pathname.endsWith(')')) {
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 1);
let item = items.filter(i => i.ID === +id)[0];
body = JSON.stringify(item);
}
else {
// not sure what might hit here yet
}
return new Response(body, { status: 200 });
}
private getAttachments(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getAttachments', urlString);
let url = parse(urlString, true, true);
let body: string;
// try to get the data from local storage and then from mock data
let items = this.mockListFactory.getListItems(this.listTitle);
// _api/web/lists/getByTitle([list name])/items([id])/AttachmentFiles
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 17);
let item = items.filter(i => i.ID === +id)[0];
if (item.AttachmentFiles !== undefined) {
body = JSON.stringify(item.AttachmentFiles);
}
else {
body = JSON.stringify([]);
}
return new Response(body, { status: 200 });
}
private getFileByServerRelativeUrl(urlString: string): Promise<Response> {
LogHelper.verbose(this.constructor.name, 'getFileByServerRelativeUrl', urlString);
return new Promise((resolve) => {
let response;
let startIndex = urlString.lastIndexOf(`(`) + 2;
let endIndex = urlString.lastIndexOf(`)`) - 1;
let filePath = urlString.substring(startIndex, endIndex);
/* TODO Revisit
if (filePath.indexOf(ApplicationValues.Path) !== -1) {
filePath = filePath.split(ApplicationValues.Path)[1];
}
*/
if (this.endsWith(urlString, '$value')) {
let xmlhttp = new XMLHttpRequest();
xmlhttp.responseType = 'arraybuffer';
// tslint:disable-next-line:no-function-expression
xmlhttp.onreadystatechange = function () {
if (xmlhttp.status === 200 && xmlhttp.readyState === 4) {
response = new Response(xmlhttp.response, { status: 200 });
resolve(response);
}
};
xmlhttp.open('GET', filePath, true);
xmlhttp.send();
}
else {
// TO DO if we need file properties
}
});
}
private getWeb(urlString: string): Response {
// only have a method for this stuff in case we need to do more mock stuff in the future
let url = parse(urlString, true, true);
let body = {
'Url': `${url.protocol}//${url.host}/`,
'ServerRelativeUrl': ''
};
return new Response(JSON.stringify(body), { status: 200 });
}
private getCurrentUser(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getCurrentUser', urlString);
// only have a method for this stuff in case we need to do more mock stuff in the future
return new Response(JSON.stringify(this.currentUser), { status: 200 });
}
private getSiteUser(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getSiteUser', urlString);
let url = parse(urlString, true, true);
let search = decodeURIComponent(url.search);
let loginName = search.substring(5, search.length - 1);
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let user = users.filter(i => {
return (i.LoginName && i.LoginName.toLowerCase().indexOf(loginName.toLowerCase()) !== -1);
})[0];
return new Response(JSON.stringify(user), { status: 200 });
}
// _api/web/siteusers/getById(1)
private getSiteUserById(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getSiteUserById', urlString);
let url = parse(urlString, true, true);
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 1);
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let user = users.filter(i => {
return (i.ID === +id);
})[0];
return new Response(JSON.stringify(user), { status: 200 });
}
// _api/web/siteusers/getByEmail('administrator@demo.com')
private getSiteUserByEmail(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getSiteUserByEmail', urlString);
let url = parse(urlString, true, true);
let pathName = decodeURIComponent(url.pathname); // get rid of encoded characters
let index = pathName.lastIndexOf(`('`);
let email = pathName.slice(index + 2, pathName.length - 2);
let user: any;
if (email.length > 0) {
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
// To better work with SharePoint and mock data...
// User Profile uses "Email"
// User Information uses "EMail"
user = users.filter(i => {
return (
i.Email ? i.Email.toLocaleLowerCase() === email.toLocaleLowerCase() : (i.EMail ? i.EMail.toLocaleLowerCase() === email.toLocaleLowerCase() : false)
);
})[0];
}
return new Response(JSON.stringify(user), { status: 200 });
}
private saveListItemChanges(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'saveListItemChanges', urlString);
let url = parse(urlString, true, true);
let body: string | undefined;
let items = this.mockListFactory.getListItems(this.listTitle);
if (url.pathname.endsWith('/items')) {
// add a new item
let item: any = {};
let storageKey = this.listTitle + '_ItemCount';
let maxId: number = 0;
if (localStorage.getItem(storageKey) !== null) {
maxId = +localStorage.getItem(storageKey)!;
if (maxId === NaN || maxId === 0) {
if (items.length > 0) {
maxId = Math.max.apply(Math, items.map(i => i.ID));
}
else {
maxId = 0;
}
}
maxId = maxId + 1;
item['ID'] = maxId;
}
let requestBody = JSON.parse(options.body);
Object.keys(requestBody).map(
(e) => item[e] = requestBody[e]
);
// Common to all SharePoint List Items
let now = new Date();
item.Created = now;
item.Modified = now;
item.AuthorId = this.currentUser.ID;
item.EditorId = this.currentUser.ID;
items.push(item);
item.Id = item.ID;
body = JSON.stringify(item);
localStorage.setItem(storageKey, JSON.stringify(maxId));
}
else if (url.pathname.endsWith(')')) {
// update
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 1);
let item = items.filter(i => i.ID === +id)[0];
if (options.body !== undefined) {
// update an item
let requestBody = JSON.parse(options.body);
Object.keys(requestBody).map(
(e) => item[e] = requestBody[e]
);
// Common to all SharePoint List Items
let now = new Date();
item.Modified = now;
item.EditorId = this.currentUser.ID;
let result: IItemUpdateResult = {
item: item,
data: { 'odata.etag': '' }
};
body = JSON.stringify(result);
}
else {
// delete an item
items = items.filter(i => i.ID !== +id);
}
}
else {
// not sure what might hit here yet
}
this.mockListFactory.saveListItems(this.listTitle, items);
return new Response(body, { status: 200 });
}
private saveAttachmentChanges(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'saveAttachmentChanges', urlString);
let url = parse(urlString, true, true);
let body: string | undefined;
let items = this.mockListFactory.getListItems(this.listTitle);
// '/reqdocs/BR/4/attachments/_api/web/lists/getByTitle(%27Requirement%20Documents%27)/items(4)/AttachmentFiles/add(FileName=%27AA%20Template.docx%27)'
let decodedPath = decodeURI(url.pathname);
let index = decodedPath.lastIndexOf('(');
let fileName = decodedPath.slice(index + 2, decodedPath.length - 2);
let startIndex = decodedPath.lastIndexOf('/items(');
let endIndex = decodedPath.lastIndexOf(')/AttachmentFiles');
let id = decodedPath.slice(startIndex + 7, endIndex);
let item = items.filter(i => i.ID === +id)[0];
if (item.AttachmentFiles === undefined) {
item.AttachmentFiles = [];
}
if (options.body !== undefined) {
// add an attachment
/*
item.AttachmentFiles.push({
FileName: options.body.name,
ServerRelativeUrl: options.body.name,
file: options.body
});
*/
let fileReader = new FileReader();
fileReader.onload = (evt: any) => {
this.fileLoaded(evt, items, item, options);
};
fileReader.readAsDataURL(options.body);
}
else {
// delete an attachment
item.AttachmentFiles = item.AttachmentFiles.filter(a => a.FileName.toLowerCase() !== fileName.toLowerCase());
}
this.mockListFactory.saveListItems(this.listTitle, items);
return new Response(body, { status: 200 });
}
private saveFile(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'saveFile', urlString);
let url = parse(urlString, true, true);
let body: string;
// /files/add(overwrite=true,url='Authorized%20Retail%20Pricing%20(effective%2004.27.18).xlsx')
let decodedPath = decodeURI(url.pathname);
let index = decodedPath.lastIndexOf('url=');
let fileName = decodedPath.slice(index + 5, decodedPath.length - 2);
// FileSaver.saveAs(options.body, fileName);
let result = {
file: options.body,
ServerRelativeUrl: fileName
};
body = JSON.stringify(result);
return new Response(body, { status: 200 });
}
private fileLoaded(evt: any, items: any, item: any, options: any) {
let data = evt.target.result;
item.AttachmentFiles.push({
FileName: options.body.name,
ServerRelativeUrl: data
});
this.mockListFactory.saveListItems(this.listTitle, items);
}
private searchPrincipals(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'searchPrincipals', urlString);
let body: string;
let searchOptions = JSON.parse(options.body);
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let items = users.filter(i => {
return ((i.DisplayName && i.DisplayName.toLowerCase().indexOf(searchOptions.input.toLowerCase()) !== -1) ||
(i.LoginName && i.LoginName.toLowerCase().indexOf(searchOptions.input.toLowerCase()) !== -1) ||
(i.Email && i.Email.toLowerCase().indexOf(searchOptions.input.toLowerCase()) !== -1)
);
});
let result = {
'SearchPrincipalsUsingContextWeb': {
'results': items
}
};
body = JSON.stringify(result);
return new Response(body, { status: 200 });
}
private clientPeoplePickerSearchUser(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'clientpeoplepickersearchuser', urlString);
let body: string;
let postBody = JSON.parse(options.body);
let query = postBody.queryParams.QueryString.toLowerCase();
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let items = users.filter(i => {
return ((i.DisplayName && i.DisplayName.toLowerCase().indexOf(query) !== -1) ||
(i.LoginName && i.LoginName.toLowerCase().indexOf(query) !== -1) ||
(i.Email && i.Email.toLowerCase().indexOf(query) !== -1)
);
});
let results: any[] = [];
for (let item of items) {
results.push({
Key: item.Key,
Description: item.Title,
DisplayText: item.DisplayName,
EntityType: 'User',
IsResolved: true,
MultipleMatches: [],
ProviderDisplayName: 'User Information List',
ProviderName: 'UserInformationList',
EntityData: {
AccountName: item.Email,
Department: '',
Title: item.JobTitle,
Email: item.Email,
MobilePhone: item.Mobile
}
});
}
let result = {
d: {
ClientPeoplePickerSearchUser: JSON.stringify(results)
}
};
body = JSON.stringify(result);
return new Response(body, { status: 200 });
}
private sendEmail(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'sendEmail', urlString);
let body: string;
/*
let emailOptions = JSON.parse(options.body);
let to = '';
if (emailOptions.properties.To !== undefined && emailOptions.properties.To !== null && emailOptions.properties.To.results.length > 0) {
for (let address of emailOptions.properties.To.results) {
if (address !== null && address.length > 0) {
to += `${address};`;
}
}
}
let cc = '';
if (emailOptions.properties.CC !== undefined && emailOptions.properties.CC !== null && emailOptions.properties.CC.results.length > 0) {
for (let address of emailOptions.properties.CC.results) {
if (address !== null && address.length > 0) {
cc += `${address};`;
}
}
}
let email = `To: ${to}\nCc: ${cc}\nSubject: ${emailOptions.properties.Subject}\nX-Unsent: 1\nContent-Type: text/html\n\n<html><body>${emailOptions.properties.Body}</body></html>`;
let data = new Blob([email], { type: 'text/plain' });
FileSaver.saveAs(data, emailOptions.properties.Subject + '.eml');
*/
body = JSON.stringify('');
return new Response(body, { status: 200 });
}
private ensureUser(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'ensureUser', urlString);
let url = parse(urlString, true, true);
let body: string;
let ensureOptions = JSON.parse(options.body);
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let user = users.filter(i => {
return (i.LoginName && i.LoginName.toLowerCase().indexOf(ensureOptions.logonName.toLowerCase()) !== -1);
})[0];
user['__metadata'] = { id: `${url.protocol}${url.host}/_api/Web/GetUserById(${user.ID})` };
user.Id = user.ID; // because... SharePoint
let result = {
'd': user
};
body = JSON.stringify(result);
return new Response(body, { status: 200 });
}
private getContextInfo(): Response {
let contexInfo: IContextInfo = {
FormDigestTimeoutSeconds: 100,
FormDigestValue: 100
};
let body = JSON.stringify({ d: { GetContextWebInformation: contexInfo } });
return new Response(body, { status: 200 });
}
private async processBatch(urlString: string, options: IFetchOptions): Promise<Response> {
let linesInBody = options.body.split('\n');
let getRequests: string[] = [];
for (let line of linesInBody) {
if (line.startsWith('GET')) {
let httpIndex = line.indexOf('http://');
let protocolIndex = line.indexOf('HTTP/1.1');
let requestUrl = line.substring(httpIndex, protocolIndex);
requestUrl = requestUrl.split('/#/').join('/');
getRequests.push(requestUrl);
}
}
// Creating response lines to look like what should be processed here
// https://github.com/pnp/pnpjs/blob/dev/packages/sp/src/batch.ts
let responseLines: string[] = [];
for (let requestUrl of getRequests) {
let getResponse = await this.fetch(requestUrl, { method: 'GET' });
responseLines.push('--batchresponse_1234');
responseLines.push('Content-Type: application/http');
responseLines.push('Content-Transfer-Encoding: binary');
responseLines.push('');
responseLines.push('HTTP/1.1 200 OK');
responseLines.push('CONTENT-TYPE: application/json;odata=verbose;charset=utf-8');
responseLines.push('');
let text = await getResponse.text();
// TODO - Revisit this as it assumes we are only batching a set of results
responseLines.push(`{"d":{"results":${text}}}`);
}
responseLines.push('--batchresponse_1234--');
responseLines.push('');
let r = responseLines.join('\n');
return new Response(r, { status: 200 });
}
private applyOrderBy(items: any[], orderby: string): any[] {
// Logger.write(`applyOrderBy`);
let sortKey: string;
let sortOrder: string;
if (orderby != null && orderby !== undefined && orderby.length > 0) {
let keys = orderby.split(' ');
sortKey = keys[0];
sortOrder = keys[1].toLocaleLowerCase();
// https://medium.com/@pagalvin/sort-arrays-using-typescript-592fa6e77f1
return items.sort((leftSide, rightSide): number => {
if (leftSide[sortKey] < rightSide[sortKey]) { return (sortOrder === 'asc' ? -1 : 1); }
if (leftSide[sortKey] > rightSide[sortKey]) { return (sortOrder === 'asc' ? 1 : -1); }
return 0;
});
}
else {
return items;
}
}
private applySelect(items: any[], select: string): any[] {
// Logger.write(`applySelect`);
let newItems: any[] = [];
if (select != null && select.length > 0) {
let keys = select.split(',');
for (let item of items) {
let newItem = {};
for (let key of keys) {
if (key.indexOf('/') === -1) {
newItem[key] = item[key];
}
else {
let partKeys = key.split('/');
this.expandedSelect(item, newItem, partKeys);
}
}
newItems.push(newItem);
}
return newItems;
}
else {
return items;
}
}
private applySkip(items: any[], skip: string): any[] {
// Logger.write(`applySkip`);
if (skip != null && +skip !== NaN) {
return items.slice(+skip);
}
else {
return items;
}
}
private applyTop(items: any[], top: string): any[] {
// Logger.write(`applyTop`);
if (top != null && +top !== NaN) {
return items.slice(0, +top);
}
else {
return items;
}
}
/*
This is intended for lookups but is a little 'hokey' and the moment since it really grabs the whole lookup object
rather than just the requested properties of the lookup. To be revisited.
*/
private expandedSelect(parentItem: any, parentNewItem: any, partKeys: string[]): any {
// Logger.write(`expandedSelect [${partKeys}]`);
try {
if (partKeys.length === 0) { return; }
let partKey = partKeys.shift();
if (parentNewItem && partKey) {
parentNewItem[partKey] = parentItem[partKey];
this.expandedSelect(parentItem[partKey], parentNewItem[partKey], partKeys);
}
}
catch (e) {
LogHelper.exception(this.constructor.name, 'expandedSelect', e);
}
}
private applyFilter(items: any[], filter: string): any[] {
// Logger.write(`applyFilter`);
let newItems: any[] = [];
if (filter != null && filter.length > 0) {
let parseResult = new FilterParser().parse(filter);
for (let item of items) {
let match: boolean = this.getMatchResult(item, parseResult);
if (match) {
newItems.push(item);
}
}
return newItems;
}
else {
return items;
}
}
private getMatchResult(item: any, parseResult: any): boolean {
switch (parseResult.operator.toLowerCase()) {
case FilterParser.Operators.EQUALS:
return this.getMatchResult_EQUALS(item, parseResult);
case FilterParser.Operators.SUBSTRINGOF:
return this.getMatchResult_SUBSTRINGOF(item, parseResult);
case FilterParser.Operators.AND:
return this.getMatchResult_AND(item, parseResult);
case FilterParser.Operators.OR:
return this.getMatchResult_OR(item, parseResult);
case FilterParser.Operators.NOT_EQUAL:
return this.getMatchResult_NOTEQUALS(item, parseResult);
default:
return false;
}
}
private getMatchResult_EQUALS(item: any, parseResult: any): boolean {
let propertyValue = item;
for (let property of parseResult.property) {
propertyValue = propertyValue[property];
// if our property is undefined or null no reason to keep looping into it
if (propertyValue === undefined || propertyValue === null) {
break;
}
// hack that for multi
if (propertyValue['results'] !== undefined) {
LogHelper.verbose(this.constructor.name, 'ensureUser', `getMatchResult_EQUALS ${property} - hack based on assumption this is a multiLookup`);
// if (property.toLowerCase().indexOf('multilookup') !== -1) {
// take the results collection and map to a single array of just the property we are matching on
propertyValue = propertyValue['results'].map(r => r[parseResult.property[1]]);
break;
}
}
let filterValue: any;
if (typeof (propertyValue) === 'number') {
filterValue = +parseResult.value;
}
else if (typeof (propertyValue) === 'boolean') {
filterValue = Boolean(parseResult.value);
}
else {
filterValue = parseResult.value;
}
if (propertyValue === filterValue) {
return true;
}
else if (Array.isArray(propertyValue)) {
if (propertyValue.indexOf(filterValue) !== -1) {
return true;
}
}
return false;
}
private getMatchResult_NOTEQUALS(item: any, parseResult: any): boolean {
let propertyValue = item;
for (let property of parseResult.property) {
propertyValue = propertyValue[property];
}
let filterValue: any;
if (typeof (propertyValue) === 'number') {
filterValue = +parseResult.value;
}
else if (typeof (propertyValue) === 'boolean') {
filterValue = Boolean(parseResult.value);
}
else {
filterValue = parseResult.value;
}
if (propertyValue !== filterValue) {
return true;
}
else if (Array.isArray(propertyValue)) {
if (propertyValue.indexOf(filterValue) === -1) {
return true;
}
}
return false;
}
private getMatchResult_SUBSTRINGOF(item: any, parseResult: any): boolean {
let propertyValue = item;
for (let property of parseResult.property) {
propertyValue = propertyValue[property];
}
let filterValue: any;
if (typeof (propertyValue) === 'number') {
filterValue = +parseResult.value;
}
else {
filterValue = parseResult.value;
}
if (propertyValue.toLowerCase().indexOf(filterValue.toLowerCase()) !== -1) {
return true;
}
return false;
}
// This assumes just one 'AND'
private getMatchResult_AND(item: any, parseResult: any): boolean {
let parseResult1 = this.getMatchResult(item, parseResult.property);
let parseResult2 = this.getMatchResult(item, parseResult.value);
if (parseResult1 === true && parseResult2 === true) {
return true;
}
else {
return false;
}
}
// This assumes just one 'OR'
private getMatchResult_OR(item: any, parseResult: any): boolean {
let parseResult1 = this.getMatchResult(item, parseResult.property);
let parseResult2 = this.getMatchResult(item, parseResult.value);
if (parseResult1 === true || parseResult2 === true) {
return true;
}
else {
return false;
}
}
private getListTitleFromUrl(urlString: string): string {
let listTitle = '';
let index = urlString.indexOf(`getByTitle('`);
if (index !== -1) {
listTitle = urlString.substring(index + 12);
index = listTitle.indexOf(`')`);
if (index !== -1) {
listTitle = listTitle.substring(0, index);
}
}
return listTitle;
}
}

View File

@ -0,0 +1,7 @@
export interface IPage {
id: number;
title: string;
etag?: string | null;
url: string;
parentPageId?: number;
}

View File

@ -0,0 +1,20 @@
export class Parameters {
public static readonly DEBUGPAGEID = 'DebugPageId';
}
export class ListTitles {
public static readonly SITEPAGES = 'Site Pages';
public static readonly USERS_INFORMATION = 'Users Information';
}
export class PageFields {
public static readonly ID = 'ID';
public static readonly TITLE = 'Title';
public static readonly FILEREF = 'FileRef';
// reply this item is related to
public static readonly PARENTPAGELOOKUP_DISPLAYNAME = 'Parent Page';
public static readonly PARENTPAGELOOKUP = 'ParentPage';
public static readonly PARENTPAGELOOKUPID = 'ParentPageId';
public static readonly PARENTPAGELOOKUP_ID = 'ParentPage/ID';
public static readonly PARENTPAGELOOKUP_TITLE = 'ParentPage/Title';
}

View File

@ -0,0 +1,10 @@
export enum PagesToDisplay {
None = 'none',
Ancestors = 'ancestors',
Children = 'children'
}
export enum RenderDirection {
Horizontal,
Vertical
}

View File

@ -0,0 +1,44 @@
import { HttpRequestError } from '@pnp/odata';
import { LogHelper } from '@src/utilities';
export class ErrorHelper {
public static handleHttpError(methodName: string, error: HttpRequestError): void {
this.logError(methodName, error);
}
public static logError(methodName: string, error: Error) {
LogHelper.exception(this.constructor.name, methodName, error);
}
public static logPnpError(methodName: string, error: HttpRequestError | any): string | undefined {
let msg: string | undefined;
if (error instanceof HttpRequestError) {
if (error.message) {
msg = error.message;
LogHelper.error(this.constructor.name, methodName, msg);
}
else {
LogHelper.exception(this.constructor.name, methodName, error);
}
}
else if (error.data != null && error.data.responseBody && error.data.responseBody.error && error.data.responseBody.error.message) {
// for email exceptions they weren't coming in as "instanceof HttpRequestError"
msg = error.data.responseBody.error.message.value;
LogHelper.error(this.constructor.name, methodName, msg!);
}
else if (error instanceof Error) {
if (error.message.indexOf('[412] Precondition Failed') !== -1) {
msg = 'Save Conflict. Your changes conflict with those made concurrently by another user. If you want your changes to be applied, resubmit your changes.';
LogHelper.error(this.constructor.name, methodName, msg);
}
else if (error.message !== 'Unexpected token < in JSON at position 0') {
// 'Unexpected token < in JSON at position 0' will be thrown if XML file is read; this was issue in MDF project
msg = error.message;
LogHelper.error(this.constructor.name, methodName, msg);
}
return msg;
}
}
}

View File

@ -0,0 +1,36 @@
import { Logger, LogLevel } from "@pnp/logging";
export class LogHelper {
public static verbose(className: string, methodName: string, message: string) {
message = this.formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Verbose);
}
public static info(className: string, methodName: string, message: string) {
message = this.formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Info);
}
public static warning(className: string, methodName: string, message: string) {
message = this.formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Warning);
}
public static error(className: string, methodName: string, message: string) {
message = this.formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Error);
}
public static exception(className: string, methodName: string, error: Error) {
error.message = this.formatMessage(className, methodName, error.message);
Logger.error(error);
}
private static formatMessage(className: string, methodName: string, message: string): string {
let d = new Date();
let dateStr = d.getDate() + '-' + (d.getMonth() + 1) + '-' + d.getFullYear() + ' ' +
d.getHours() + ':' + d.getMinutes() + ':' + d.getSeconds() + '.' + d.getMilliseconds();
return `${dateStr} ${className} > ${methodName} > ${message}`;
}
}

View File

@ -0,0 +1,4 @@
export * from './Constants';
export * from './Enums';
export * from './ErrorHelper';
export * from './LogHelper';

View File

@ -0,0 +1,37 @@
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { Environment, EnvironmentType } from '@microsoft/sp-core-library';
import { sp } from "@pnp/sp";
import { Logger, ConsoleListener, LogLevel } from "@pnp/logging";
import { CustomFetchClient } from '@src/mocks/customfetchclient';
export default class BaseWebPart<TProperties> extends BaseClientSideWebPart<TProperties> {
protected async onInit(): Promise<void> {
let isUsingSharePoint = true;
if (Environment.type === EnvironmentType.Local || Environment.type === EnvironmentType.Test) {
isUsingSharePoint = false;
}
return super.onInit().then(_ => {
sp.setup({
spfxContext: this.context,
sp: {
fetchClientFactory: () => {
return new CustomFetchClient(isUsingSharePoint);
},
}
});
// subscribe a listener
Logger.subscribe(new ConsoleListener());
// set the active log level -- eventually make this a web part property
Logger.activeLogLevel = LogLevel.Verbose;
});
}
public render(): void {
}
}

View File

@ -0,0 +1,7 @@
import { PagesToDisplay } from '@src/utilities';
export default interface IPageHierarchyWebPartProps {
title: string;
debugPageId?: number;
pagesToDisplay: PagesToDisplay;
}

View File

@ -0,0 +1,31 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "d1ba5a4d-dea3-4eb2-a8c8-c652f58cbc39",
"alias": "PageHierarchyWebPart",
"componentType": "WebPart",
"supportsThemeVariants": true,
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"supportedHosts": [
"SharePointWebPart"
],
"preconfiguredEntries": [
{
"groupId": "1edbd9a8-0bfb-4aa2-9afd-14b8c45dd489",
"group": {
"default": "Discover"
},
"title": {
"default": "Page Hierarchy"
},
"description": {
"default": "Breadcrumbs description"
},
"officeFabricIconFontName": "CompassNW",
"properties": {
"pagesToDisplay": "none"
}
}
]
}

View File

@ -0,0 +1,177 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { ThemeProvider, IReadonlyTheme, ThemeChangedEventArgs } from '@microsoft/sp-component-base';
import { Version, DisplayMode } from '@microsoft/sp-core-library';
import { IPropertyPaneConfiguration, IPropertyPaneGroup, PropertyPaneChoiceGroup, PropertyPaneLabel } from '@microsoft/sp-property-pane';
import { UrlQueryParameterCollection } from '@microsoft/sp-core-library';
import { PropertyFieldNumber } from '@pnp/spfx-property-controls/lib/PropertyFieldNumber';
import BaseWebPart from '@src/webparts/BaseWebPart';
import { Parameters, PagesToDisplay, LogHelper } from '@src/utilities';
import IPageHierarchyWebPartProps from './IPageHierarchyWebPartProps';
import * as strings from 'PageHierarchyWebPartStrings';
import { Container, IContainerProps } from './components/Container';
export default class PageHierarchyWebPart extends BaseWebPart<IPageHierarchyWebPartProps> {
private themeProvider: ThemeProvider;
private themeVariant: IReadonlyTheme | undefined;
// since this web part reacts to page changes that need to be saved before we redraw using this boolean to know that
private pageEditFinished: boolean = false;
protected onInit(): Promise<void> {
// Consume the new ThemeProvider service
this.themeProvider = this.context.serviceScope.consume(ThemeProvider.serviceKey);
// If it exists, get the theme variant
this.themeVariant = this.themeProvider.tryGetTheme();
// Register a handler to be notified if the theme variant changes
this.themeProvider.themeChangedEvent.add(this, this.handleThemeChangedEvent);
return super.onInit();
}
/*
we force the user to make a decision about what to display (ancestors vs children) in the Configuration Control
Once we know that we can render the container whose only job is to play traffic cop for the controls. Eventually
there may be many different rendering controls for children
*/
public render(): void {
LogHelper.verbose('PageHierarchyWebPart', 'render', JSON.stringify(this.domElement.getBoundingClientRect()));
const element: React.ReactElement<IContainerProps> = React.createElement(
Container,
{
currentPageId: this.context.pageContext.listItem ? this.context.pageContext.listItem.id : this.getDebugPageId(),
pagesToDisplay: this.properties.pagesToDisplay,
themeVariant: this.themeVariant,
domElement: this.domElement,
showTitle: true,
title: this.properties.title,
displayMode: this.displayMode,
updateTitle: (t) => { this.properties.title = t; this.render(); },
onConfigure: () => { this.onConfigure(); },
pageEditFinished: this.pageEditFinished
}
);
ReactDom.render(element, this.domElement, () => {
// ensure flag is reset to false after render is finished
this.pageEditFinished = false;
});
}
private handleThemeChangedEvent(args: ThemeChangedEventArgs): void {
this.themeVariant = args.theme;
this.render();
}
private onConfigure = (): void => {
this.context.propertyPane.open();
}
/*
Really only used for workbench mode when we cannot get a page id for the current page.
We'll allow user to test with a property and also using mock data allow them to navigate when on local host with a querystring
*/
private getDebugPageId() : number {
let queryParms = new UrlQueryParameterCollection(window.location.href);
let debugPageId = this.properties.debugPageId;
if(queryParms.getValue(Parameters.DEBUGPAGEID)) { debugPageId = Number(queryParms.getValue(Parameters.DEBUGPAGEID)); }
return debugPageId;
}
/*
when page edit goes from edit to read we start a timer so that we can wait for the save to occur
Things like the page title and page parent page property changing affect us
*/
protected onDisplayModeChanged(oldDisplayMode: DisplayMode) {
if (oldDisplayMode === DisplayMode.Edit) {
setTimeout(() => {
this.pageEditFinished = true;
this.render();
}, 500);
}
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
let propertyPaneGroups: IPropertyPaneGroup[] = [];
// If this webpart isn't on a page, we don't have a list item so let us provide our own to debug
if (this.context.pageContext.listItem === undefined) {
propertyPaneGroups.push({
groupName: strings.PropertyPane_GroupName_Debug,
isCollapsed: false,
groupFields: [
PropertyFieldNumber('debugPageId', {
key: 'debugPageId',
value: this.properties.debugPageId,
label: strings.PropertyPane_Label_DebugPageId,
description: strings.PropertyPane_Description_DebugPageId,
minValue: 1,
disabled: false
})
]
});
}
// add group for choosing display mode
propertyPaneGroups.push({
groupName: strings.PropertyPane_GroupName_PagesToDisplay,
isCollapsed: false,
groupFields: [
PropertyPaneChoiceGroup('pagesToDisplay', {
label: strings.PropertyPane_Label_PagesToDisplay,
options: [
{
key: PagesToDisplay.Ancestors,
text: strings.PropertyPane_PagesToDisplay_OptionText_Ancestors,
checked: this.properties.pagesToDisplay === PagesToDisplay.Ancestors,
iconProps: { officeFabricIconFontName: 'ChevronRightMed' }
},
{
key: PagesToDisplay.Children,
text: strings.PropertyPane_PagesToDisplay_OptionText_Children,
checked: this.properties.pagesToDisplay === PagesToDisplay.Children,
iconProps: { officeFabricIconFontName: 'DistributeDown' }
}
]
})
]
});
propertyPaneGroups.push({
groupName: strings.PropertyPane_GroupName_About,
isCollapsed: false,
groupFields: [
PropertyPaneLabel('versionNumber', {
text: strings.PropertyPane_Label_VersionInfo + this.manifest.version
})
]
});
return {
pages: [
{
header: {
description: strings.PropertyPane_Description
},
displayGroupsAsAccordion: true,
groups: propertyPaneGroups
}
]
};
}
}

View File

@ -0,0 +1,8 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.defaultContainer {
.container {
margin: 0px auto;
}
}

View File

@ -0,0 +1,62 @@
import * as React from 'react';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import styles from './Container.module.scss';
import * as strings from 'PageHierarchyWebPartStrings';
import { BreadcrumbLayout, ListLayout } from '../Layouts';
import { IContainerProps } from './IContainerProps';
import { PagesToDisplay } from '@src/utilities';
import { usePageApi } from '@src/apiHooks/usePageApi';
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
export const Container: React.FunctionComponent<IContainerProps> = props => {
const pagesApi = usePageApi(props.currentPageId, props.pageEditFinished);
let controlToRender = undefined;
switch (props.pagesToDisplay) {
case PagesToDisplay.Ancestors:
controlToRender = <BreadcrumbLayout domElement={props.domElement} pages={pagesApi.state.ancestorPages} themeVariant={props.themeVariant} />;
break;
case PagesToDisplay.Children:
controlToRender = <ListLayout domElement={props.domElement} pages={pagesApi.state.childrenPages} themeVariant={props.themeVariant} />;
break;
default:
controlToRender = <div>
<Placeholder
iconName='Edit'
iconText={strings.Configuration_Placeholder_IconText}
description={strings.Configuration_Placeholder_Description}
buttonLabel={strings.Configuration_Placeholder_ButtonLabel}
onConfigure={props.onConfigure} />
</div>;
break;
}
// if the parent page column was never created or it was deleted force them to recreate it
if (!pagesApi.state.parentPageColumnExists) {
var description = strings.ParentPageMissing_Placeholder_Description;
if(!pagesApi.state.userCanManagePages) {
description = strings.ParentPageMissing_Placeholder_Description_NoPermissions;
}
controlToRender =
<div>
<Placeholder
iconName='FieldRequired'
hideButton={!pagesApi.state.userCanManagePages}
iconText={strings.ParentPageMissing_Placeholder_IconText}
description={description}
buttonLabel={strings.ParentPageMissing_Placeholder_ButtonLabel}
onConfigure={pagesApi.addParentPageField} />
</div>;
}
return (
<div className={styles.defaultContainer}>
<div className={styles.container}>
{props.showTitle && <WebPartTitle themeVariant={props.themeVariant} title={props.title} displayMode={props.displayMode} updateProperty={props.updateTitle} />}
{controlToRender}
</div>
</div>
);
};

View File

@ -0,0 +1,18 @@
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { PagesToDisplay } from '@src/utilities';
import { DisplayMode } from "@microsoft/sp-core-library";
export interface IContainerProps {
currentPageId: number;
pagesToDisplay: PagesToDisplay;
themeVariant: IReadonlyTheme;
domElement: HTMLElement;
// all this is just for WebPartTitle control
showTitle: boolean;
title: string;
displayMode: DisplayMode;
updateTitle: (value: string) => void;
onConfigure: () => void;
pageEditFinished: boolean;
}

View File

@ -0,0 +1,2 @@
export * from './Container';
export * from './IContainerProps';

View File

@ -0,0 +1,95 @@
import * as React from 'react';
import { useState } from 'react';
import styles from './Layouts.module.scss';
import { RenderDirection, LogHelper } from '@src/utilities';
import { IPage } from '@src/models/IPage';
import { ActionButton, Icon } from 'office-ui-fabric-react';
import { ILayoutProps } from './ILayoutProps';
import ReactResizeDetector from 'react-resize-detector';
import * as strings from 'PageHierarchyWebPartStrings';
export const BreadcrumbLayout: React.FunctionComponent<ILayoutProps> = props => {
const [elementWidth, setElementWidth] = useState(props.domElement.getBoundingClientRect().width);
// 455 was chosen because that's the smallest break pointin 2 column before it wraps and stacks
let renderDirection = elementWidth > 455 ? RenderDirection.Horizontal : RenderDirection.Vertical;
const renderPageAsBreadcrumb = (page: IPage, index: number, pages: IPage[]) => {
if (page) {
return (
<li key={page.id} className={styles.breadcrumbLayoutItem}>
<span className={styles.breadcrumbLayoutItemContainer}>
<ActionButton
style={{
color: props.themeVariant.semanticColors.bodyText
}}
className={styles.breadcrumbLayoutItemButton}
href={page.url}
target="_self">
{page.title}
</ActionButton>
</span>
{index + 1 !== pages.length ?
(
<Icon iconName="ChevronRight" className={styles.breadcrumbLayoutHorizontalIcon} />
) : null
}
</li>
);
}
};
const renderPageAsStack = (page: IPage, index: number, pages: IPage[]) => {
if (page) {
return (
<li key={page.id} className={styles.breadcrumbLayoutItem}>
<Icon iconName="ChevronDown" className={styles.breadcrumbLayoutVerticalIcon} />
<span className={styles.breadcrumbLayoutItemContainer}>
<ActionButton
style={{
color: props.themeVariant.semanticColors.bodyText
}}
className={styles.breadcrumbLayoutItemButton}
href={page.url}
target="_self">
{page.title}
</ActionButton>
</span>
</li>
);
}
};
const renderPages = (pages: IPage[], ) => {
if (renderDirection === RenderDirection.Horizontal) {
return pages.map((value, index, array) => renderPageAsBreadcrumb(value, index, array));
}
else {
return pages.map((value, index, array) => renderPageAsStack(value, index, array));
}
};
const onResize = () => {
setElementWidth(props.domElement.getBoundingClientRect().width);
};
//<div>DOM Element width: {elementWidth}</div>
return (
<div className={styles.layouts}>
{props.pages.length > 0 ? (
<ul className={renderDirection === RenderDirection.Horizontal ? styles.breadcrumbLayoutHorizontal : styles.breadcrumbLayoutVertical}>
{renderPages(props.pages)}
</ul>
) : (
<span>{strings.Message_NoAncestorsFound}</span>
)}
<ReactResizeDetector handleWidth handleHeight onResize={onResize} />
</div>
);
};

View File

@ -0,0 +1,8 @@
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { IPage } from '@src/models/IPage';
export interface ILayoutProps {
domElement: HTMLElement;
pages: IPage[];
themeVariant: IReadonlyTheme;
}

View File

@ -0,0 +1,91 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.layouts {
/* START: BreadcrumbLayout */
.breadcrumbLayoutHorizontal {
display: flex;
align-items: flex-start;
flex-flow: wrap;
margin: 0;
padding: 0;
}
.breadcrumbLayoutVertical {
display: flex;
align-items: flex-start;
flex-direction: column;
flex-flow: wrap;
margin: 0;
padding: 0;
}
.breadcrumbLayoutItem {
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
position: relative;
align-items: center;
@include ms-font-xl;
}
.breadcrumbLayoutItemContainer {
padding: 0;
}
.breadcrumbLayoutItemButton {
@include ms-font-l;
@include ms-fontWeight-light;
}
.breadcrumbLayoutHorizontalIcon {
@include ms-font-xl;
}
.breadcrumbLayoutVerticalIcon {
@include ms-font-l;
}
/* END: breadcrumbLayout */
/* START: ListLayout */
.listLayout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
// grid-auto-rows: 20px;
grid-gap: 10px;
padding: 0;
/*
display: flex;
margin: 0;
padding: 0;
flex-direction: row;
flex-wrap: wrap;
*/
}
.listLayoutItem {
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
position: relative;
align-items: left;
@include ms-font-xl;
}
.listLayoutItemContainer {
padding: 0;
width: 100%;
};
.listLayoutItemButton {
@include ms-font-m-plus;
@include ms-fontWeight-regular;
display: block;
:global .ms-Button-textContainer {
width: calc(100% - 10px);
}
:global .ms-Button-label {
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 18px;
}
};
/* END: ListLayout */
}

View File

@ -0,0 +1,79 @@
import * as React from 'react';
import { useState } from 'react';
import styles from './Layouts.module.scss';
import { IPage } from '@src/models/IPage';
import { Icon, ActionButton } from 'office-ui-fabric-react';
import { ILayoutProps } from './ILayoutProps';
import ReactResizeDetector from 'react-resize-detector';
import * as strings from 'PageHierarchyWebPartStrings';
export const ListLayout: React.FunctionComponent<ILayoutProps> = props => {
const [elementWidth, setElementWidth] = useState(props.domElement.getBoundingClientRect().width);
/*
min page width before responsive 1024
* 3 column = each 33.33%
* 1/3 left or right (33.33% & 66.66%)
* 1/2 (50%)
one column =
two column = 586
three column = 380
need to figure out how wide we make buttons based on container
https://developer.microsoft.com/en-us/fluentui#/controls/web/stack
Horizontal Stack - Wrapping - Advanced
*/
if (elementWidth < 380) {
}
const renderPage = (page: IPage, index: number, pages: IPage[]) => {
if (page) {
return (
<li className={styles.listLayoutItem}>
<div className={styles.listLayoutItemContainer}
style={{
backgroundColor: props.themeVariant.semanticColors.primaryButtonBackground
}}
>
<ActionButton
style={{
color: props.themeVariant.semanticColors.primaryButtonText
}}
className={styles.listLayoutItemButton}
href={page.url}
target="_self">{page.title}
</ActionButton>
</div>
</li>
);
}
};
const renderPages = (pages: IPage[], ) => {
return pages.map((value, index, array) => renderPage(value, index, array));
};
const onResize = () => {
setElementWidth(props.domElement.getBoundingClientRect().width);
};
//<div>DOM Element width: {elementWidth}</div>
return (
<div className={styles.layouts}>
{props.pages.length > 0 ? (
<ul className={styles.listLayout}>
{renderPages(props.pages)}
</ul>
) : (
<span>{strings.Message_NoChildrenFound}</span>
)}
<ReactResizeDetector handleWidth handleHeight onResize={onResize} />
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from './ILayoutProps';
export * from './BreadcrumbLayout';
export * from './ListLayout';

View File

@ -0,0 +1,23 @@
define([], function() {
return {
"Configuration_Placeholder_IconText": "Configure Page Hierarchy Web Part",
"Configuration_Placeholder_Description": "Please configure the web part.",
"Configuration_Placeholder_ButtonLabel": "Configure",
"Message_NoAncestorsFound": "No ancestors found for page. Update the parent page property to select a parent",
"Message_NoChildrenFound": "No children found for page. Update the parent page property for other pages to set this as their parent.",
"ParentPageMissing_Placeholder_IconText": "Parent Page Property is missing",
"ParentPageMissing_Placeholder_Description": "Click button to add Parent Page Property to Site Pages",
"ParentPageMissing_Placeholder_Description_NoPermissions": "You don't have permissions to add the property. Have a Site Owner edit this web part to enable the Parent Page Property.",
"ParentPageMissing_Placeholder_ButtonLabel": "Enable",
"PropertyPane_Description": "Show page to page navigation items based on the relationships of your pages to each other.",
"PropertyPane_GroupName_About": "About Webpart",
"PropertyPane_GroupName_PagesToDisplay": "Pages To Display",
"PropertyPane_Label_PagesToDisplay": "Choose pages to show based on their relationships to this page",
"PropertyPane_PagesToDisplay_OptionText_Ancestors": "Ancestor Pages",
"PropertyPane_PagesToDisplay_OptionText_Children": "Children Pages",
"PropertyPane_GroupName_Debug": "Debug",
"PropertyPane_Label_DebugPageId": "Debug Page Id",
"PropertyPane_Label_VersionInfo": "Version: ",
"PropertyPane_Description_DebugPageId": "Provide a valid page list item id to see how the web part would render for it"
}
});

View File

@ -0,0 +1,26 @@
declare interface IPageHierarchyWebPartStrings {
Configuration_Placeholder_IconText: string;
Configuration_Placeholder_Description: string;
Configuration_Placeholder_ButtonLabel: string;
Message_NoAncestorsFound: string;
Message_NoChildrenFound: string;
ParentPageMissing_Placeholder_IconText: string;
ParentPageMissing_Placeholder_Description: string;
ParentPageMissing_Placeholder_Description_NoPermissions: string;
ParentPageMissing_Placeholder_ButtonLabel: string;
PropertyPane_Description: string;
PropertyPane_GroupName_About: string;
PropertyPane_GroupName_PagesToDisplay: string;
PropertyPane_Label_PagesToDisplay: string;
PropertyPane_PagesToDisplay_OptionText_Ancestors: string;
PropertyPane_PagesToDisplay_OptionText_Children: string;
PropertyPane_GroupName_Debug: string;
PropertyPane_Label_DebugPageId: string;
PropertyPane_Label_VersionInfo: string;
PropertyPane_Description_DebugPageId: string;
}
declare module 'PageHierarchyWebPartStrings' {
const strings: IPageHierarchyWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,46 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@src/*": [
"src/*"
]
},
"inlineSources": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"es6-promise",
"webpack-env"
],
"lib": [
// commented out to allow for this.constructor.name support
// "es5",
"es2017",
"dom",
"es2015.collection"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"lib"
]
}

View File

@ -0,0 +1,30 @@
{
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
"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-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,
"variable-name": false,
"whitespace": false
}
}