Added new react-pages-hierarchy web part
This commit is contained in:
parent
a11cb52af7
commit
2ac96bf170
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
|
@ -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 |
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
|
||||||
|
"deployCdnPath": "temp/deploy"
|
||||||
|
}
|
|
@ -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 -->"
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||||
|
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||||
|
}
|
|
@ -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
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface Action {
|
||||||
|
type: string;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface GetRequest {
|
||||||
|
isLoading: boolean;
|
||||||
|
hasError: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
// A file is required to be in the root of the /src directory by the TypeScript compiler
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './pagesList';
|
||||||
|
export * from './userInformationList';
|
|
@ -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
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
|
@ -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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface IPage {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
etag?: string | null;
|
||||||
|
url: string;
|
||||||
|
parentPageId?: number;
|
||||||
|
}
|
|
@ -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';
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
export enum PagesToDisplay {
|
||||||
|
None = 'none',
|
||||||
|
Ancestors = 'ancestors',
|
||||||
|
Children = 'children'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RenderDirection {
|
||||||
|
Horizontal,
|
||||||
|
Vertical
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './Constants';
|
||||||
|
export * from './Enums';
|
||||||
|
export * from './ErrorHelper';
|
||||||
|
export * from './LogHelper';
|
|
@ -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 {
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { PagesToDisplay } from '@src/utilities';
|
||||||
|
|
||||||
|
export default interface IPageHierarchyWebPartProps {
|
||||||
|
title: string;
|
||||||
|
debugPageId?: number;
|
||||||
|
pagesToDisplay: PagesToDisplay;
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
@import '~office-ui-fabric-react/dist/sass/References.scss';
|
||||||
|
|
||||||
|
.defaultContainer {
|
||||||
|
.container {
|
||||||
|
margin: 0px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './Container';
|
||||||
|
export * from './IContainerProps';
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
|
@ -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 */
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './ILayoutProps';
|
||||||
|
export * from './BreadcrumbLayout';
|
||||||
|
export * from './ListLayout';
|
|
@ -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"
|
||||||
|
}
|
||||||
|
});
|
|
@ -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 |
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue