Initial commit of Rhythm of Business Calendar app sample (#1)

Co-authored-by: d-turley <daniel.p.turley@avanade.com>
This commit is contained in:
d-turley 2022-09-21 15:35:23 -07:00 committed by GitHub
parent 3fb4d929d3
commit d4a1546ddc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
357 changed files with 90550 additions and 0 deletions

View File

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

View File

@ -0,0 +1,372 @@
require('@rushstack/eslint-config/patch/modern-module-resolution');
module.exports = {
extends: ['@microsoft/eslint-config-spfx/lib/profiles/react'],
parserOptions: { tsconfigRootDir: __dirname },
overrides: [
{
files: ['*.ts', '*.tsx'],
parser: '@typescript-eslint/parser',
'parserOptions': {
'project': './tsconfig.json',
'ecmaVersion': 2018,
'sourceType': 'module'
},
rules: {
// Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin
'@rushstack/no-new-null': 1,
// Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin
'@rushstack/hoist-jest-mock': 1,
// Require regular expressions to be constructed from string constants rather than dynamically building strings at runtime. https://www.npmjs.com/package/@rushstack/eslint-plugin-security
'@rushstack/security/no-unsafe-regexp': 1,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'@typescript-eslint/adjacent-overload-signatures': 1,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
//
// CONFIGURATION: By default, these are banned: String, Boolean, Number, Object, Symbol
'@typescript-eslint/ban-types': [
1,
{
'extendDefaults': false,
'types': {
'String': {
'message': 'Use \'string\' instead',
'fixWith': 'string'
},
'Boolean': {
'message': 'Use \'boolean\' instead',
'fixWith': 'boolean'
},
'Number': {
'message': 'Use \'number\' instead',
'fixWith': 'number'
},
'Object': {
'message': 'Use \'object\' instead, or else define a proper TypeScript type:'
},
'Symbol': {
'message': 'Use \'symbol\' instead',
'fixWith': 'symbol'
},
'Function': {
'message': 'The \'Function\' type accepts any function-like value.\nIt provides no type safety when calling the function, which can be a common source of bugs.\nIt also accepts things like class declarations, which will throw at runtime as they will not be called with \'new\'.\nIf you are expecting the function to accept certain arguments, you should explicitly define the function shape.'
}
}
}
],
'@typescript-eslint/no-empty-function': 'off',
// RATIONALE: Code is more readable when the type of every variable is immediately obvious.
// Even if the compiler may be able to infer a type, this inference will be unavailable
// to a person who is reviewing a GitHub diff. This rule makes writing code harder,
// but writing code is a much less important activity than reading it.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'@typescript-eslint/explicit-function-return-type': 'off',
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
// Rationale to disable: although this is a recommended rule, it is up to dev to select coding style.
// Set to 1 (warning) or 2 (error) to enable.
'@typescript-eslint/explicit-member-accessibility': 0,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'@typescript-eslint/no-array-constructor': 1,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
//
// RATIONALE: The "any" keyword disables static type checking, the main benefit of using TypeScript.
// This rule should be suppressed only in very special cases such as JSON.stringify()
// where the type really can be anything. Even if the type is flexible, another type
// may be more appropriate such as "unknown", "{}", or "Record<k,V>".
'@typescript-eslint/no-explicit-any': 'off',
// RATIONALE: The #1 rule of promises is that every promise chain must be terminated by a catch()
// handler. Thus wherever a Promise arises, the code must either append a catch handler,
// or else return the object to a caller (who assumes this responsibility). Unterminated
// promise chains are a serious issue. Besides causing errors to be silently ignored,
// they can also cause a NodeJS process to terminate unexpectedly.
'@typescript-eslint/no-floating-promises': 'off',
// RATIONALE: Catches a common coding mistake.
'@typescript-eslint/no-for-in-array': 2,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'@typescript-eslint/no-misused-new': 2,
// RATIONALE: The "namespace" keyword is not recommended for organizing code because JavaScript lacks
// a "using" statement to traverse namespaces. Nested namespaces prevent certain bundler
// optimizations. If you are declaring loose functions/variables, it's better to make them
// static members of a class, since classes support property getters and their private
// members are accessible by unit tests. Also, the exercise of choosing a meaningful
// class name tends to produce more discoverable APIs: for example, search+replacing
// the function "reverse()" is likely to return many false matches, whereas if we always
// write "Text.reverse()" is more unique. For large scale organization, it's recommended
// to decompose your code into separate NPM packages, which ensures that component
// dependencies are tracked more conscientiously.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'@typescript-eslint/no-namespace': [
1,
{
'allowDeclarations': false,
'allowDefinitionFiles': false
}
],
// RATIONALE: Parameter properties provide a shorthand such as "constructor(public title: string)"
// that avoids the effort of declaring "title" as a field. This TypeScript feature makes
// code easier to write, but arguably sacrifices readability: In the notes for
// "@typescript-eslint/member-ordering" we pointed out that fields are central to
// a class's design, so we wouldn't want to bury them in a constructor signature
// just to save some typing.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
// Set to 1 (warning) or 2 (error) to enable the rule
'@typescript-eslint/no-parameter-properties': 0,
// RATIONALE: When left in shipping code, unused variables often indicate a mistake. Dead code
// may impact performance.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'@typescript-eslint/no-unused-vars': [
1,
{
'vars': 'all',
// Unused function arguments often indicate a mistake in JavaScript code. However in TypeScript code,
// the compiler catches most of those mistakes, and unused arguments are fairly common for type signatures
// that are overriding a base class method or implementing an interface.
'args': 'none'
}
],
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'@typescript-eslint/no-use-before-define': [
2,
{
'functions': false,
'classes': true,
'variables': true,
'enums': true,
'typedefs': true
}
],
// Disallows require statements except in import statements.
// In other words, the use of forms such as var foo = require("foo") are banned. Instead use ES6 style imports or import foo = require("foo") imports.
'@typescript-eslint/no-var-requires': 'off',
// RATIONALE: The "module" keyword is deprecated except when describing legacy libraries.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'@typescript-eslint/prefer-namespace-keyword': 1,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
// Rationale to disable: it's up to developer to decide if he wants to add type annotations
// Set to 1 (warning) or 2 (error) to enable the rule
'@typescript-eslint/no-inferrable-types': 0,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
// Rationale to disable: declaration of empty interfaces may be helpful for generic types scenarios
'@typescript-eslint/no-empty-interface': 'off',
// RATIONALE: This rule warns if setters are defined without getters, which is probably a mistake.
'accessor-pairs': 1,
// RATIONALE: In TypeScript, if you write x["y"] instead of x.y, it disables type checking.
'dot-notation': [
1,
{
'allowPattern': '^_'
}
],
// RATIONALE: Catches code that is likely to be incorrect
'eqeqeq': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'for-direction': 1,
// RATIONALE: Catches a common coding mistake.
'guard-for-in': 2,
// RATIONALE: If you have more than 2,000 lines in a single source file, it's probably time
// to split up your code.
'max-lines': ['warn', { max: 2000 }],
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-async-promise-executor': 2,
// RATIONALE: Deprecated language feature.
'no-caller': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-compare-neg-zero': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-cond-assign': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-constant-condition': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-control-regex': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-debugger': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-delete-var': 2,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-duplicate-case': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-empty': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-empty-character-class': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-empty-pattern': 1,
// RATIONALE: Eval is a security concern and a performance concern.
'no-eval': 1,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-ex-assign': 2,
// RATIONALE: System types are global and should not be tampered with in a scalable code base.
// If two different libraries (or two versions of the same library) both try to modify
// a type, only one of them can win. Polyfills are acceptable because they implement
// a standardized interoperable contract, but polyfills are generally coded in plain
// JavaScript.
'no-extend-native': 1,
// Disallow unnecessary labels
'no-extra-label': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-fallthrough': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-func-assign': 1,
// RATIONALE: Catches a common coding mistake.
'no-implied-eval': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-invalid-regexp': 2,
// RATIONALE: Catches a common coding mistake.
'no-label-var': 2,
// RATIONALE: Eliminates redundant code.
'no-lone-blocks': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-misleading-character-class': 2,
// RATIONALE: Catches a common coding mistake.
'no-multi-str': 2,
// RATIONALE: It's generally a bad practice to call "new Thing()" without assigning the result to
// a variable. Either it's part of an awkward expression like "(new Thing()).doSomething()",
// or else implies that the constructor is doing nontrivial computations, which is often
// a poor class design.
'no-new': 1,
// RATIONALE: Obsolete language feature that is deprecated.
'no-new-func': 2,
// RATIONALE: Obsolete language feature that is deprecated.
'no-new-object': 2,
// RATIONALE: Obsolete notation.
'no-new-wrappers': 1,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-octal': 2,
// RATIONALE: Catches code that is likely to be incorrect
'no-octal-escape': 2,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-regex-spaces': 2,
// RATIONALE: Catches a common coding mistake.
'no-return-assign': 'off',
// RATIONALE: Security risk.
'no-script-url': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-self-assign': 2,
// RATIONALE: Catches a common coding mistake.
'no-self-compare': 2,
// RATIONALE: This avoids statements such as "while (a = next(), a && a.length);" that use
// commas to create compound expressions. In general code is more readable if each
// step is split onto a separate line. This also makes it easier to set breakpoints
// in the debugger.
'no-sequences': 1,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-shadow-restricted-names': 2,
// RATIONALE: Obsolete language feature that is deprecated.
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-sparse-arrays': 2,
// RATIONALE: Although in theory JavaScript allows any possible data type to be thrown as an exception,
// such flexibility adds pointless complexity, by requiring every catch block to test
// the type of the object that it receives. Whereas if catch blocks can always assume
// that their object implements the "Error" contract, then the code is simpler, and
// we generally get useful additional information like a call stack.
'no-throw-literal': 2,
// RATIONALE: Catches a common coding mistake.
'no-unmodified-loop-condition': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-unsafe-finally': 2,
// RATIONALE: Catches a common coding mistake.
'no-unused-expressions': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-unused-labels': 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-useless-catch': 1,
// RATIONALE: Avoids a potential performance problem.
'no-useless-concat': 1,
// RATIONALE: The "var" keyword is deprecated because of its confusing "hoisting" behavior.
// Always use "let" or "const" instead.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
'no-var': 2,
// RATIONALE: Generally not needed in modern code.
'no-void': 1,
// RATIONALE: Obsolete language feature that is deprecated.
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'no-with': 2,
// RATIONALE: Makes logic easier to understand, since constants always have a known value
// @typescript-eslint\eslint-plugin\dist\configs\eslint-recommended.js
'prefer-const': 1,
// RATIONALE: Catches a common coding mistake where "resolve" and "reject" are confused.
'promise/param-names': 2,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'require-atomic-updates': 'off',
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'require-yield': 1,
// "Use strict" is redundant when using the TypeScript compiler.
'strict': [
2,
'never'
],
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
'use-isnan': 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
// Set to 1 (warning) or 2 (error) to enable.
// Rationale to disable: !!{}
'no-extra-boolean-cast': 0,
// ====================================================================
// @microsoft/eslint-plugin-spfx
// ====================================================================
'@microsoft/spfx/import-requires-chunk-name': 1,
'@microsoft/spfx/no-require-ensure': 2,
'@microsoft/spfx/pair-react-dom-render-unmount': 1
}
},
{
// For unit tests, we can be a little bit less strict. The settings below revise the
// defaults specified in the extended configurations, as well as above.
files: [
// Test files
'*.test.ts',
'*.test.tsx',
'*.spec.ts',
'*.spec.tsx',
// Facebook convention
'**/__mocks__/*.ts',
'**/__mocks__/*.tsx',
'**/__tests__/*.ts',
'**/__tests__/*.tsx',
// Microsoft convention
'**/test/*.ts',
'**/test/*.tsx'
],
rules: {
'no-new': 0,
'class-name': 0,
'export-name': 0,
forin: 0,
'label-position': 0,
'member-access': 2,
'no-arg': 0,
'no-console': 0,
'no-construct': 0,
'no-duplicate-variable': 2,
'no-eval': 0,
'no-function-expression': 2,
'no-internal-module': 2,
'no-shadowed-variable': 2,
'no-switch-case-fall-through': 2,
'no-unnecessary-semicolons': 2,
'no-unused-expression': 2,
'no-with-statement': 2,
semicolon: 2,
'trailing-comma': 0,
typedef: 0,
'typedef-whitespace': 0,
'use-named-parameter': 2,
'variable-name': 0,
whitespace: 0
}
}
]
};

View File

@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

View File

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

View File

@ -0,0 +1,12 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.15.2",
"libraryName": "rhythm-of-business-calendar",
"libraryId": "58f92635-9ea6-43cc-a136-3daae0c68678",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,124 @@
# Rhythm of Business Calendar
## Summary
This sample is the source code for the Rhythm of Business Calendar app published in [AppSource](https://appsource.microsoft.com/en-us/marketplace/apps?product=sharepoint) and is intended to demonstrate patterns and practices for building enterprise apps on the SharePoint platform.
Rhythm of Business (RoB) Calendar keeps you on top of your business goals by managing all team and organizational events seamlessly. Simplify and expedite the coordination and planning process for your team and subgroups with the help of color-coded events, approval workflow, refiners and confidential events. Ideal for Chiefs of Staff, Executive Assistants, or anyone who manages a team calendar, you can empower your teams by enabling better insights on your business goals and team events.
Month view
![Screenshot of month view](./assets/screenshot-month-view.png)
View event details
![Screenshot of view event panel](./assets/screenshot-view-event.png)
Edit refiner
![Screenshot of edit refiner panel](./assets/screenshot-edit-refiner.png)
## Compatibility
![SPFx 1.15](https://img.shields.io/badge/SPFx-1.15-green.svg)
![Node.js v14 | v12](https://img.shields.io/badge/Node.js-v16-green.svg)
![Compatible with SharePoint Online](https://img.shields.io/badge/SharePoint%20Online-Compatible-green.svg)
![Does not work with SharePoint 2019](https://img.shields.io/badge/SharePoint%20Server%202019-Incompatible-red.svg "SharePoint Server 2019 requires SPFx 1.4.1 or lower")
![Does not work with SharePoint 2016 (Feature Pack 2)](https://img.shields.io/badge/SharePoint%20Server%202016%20(Feature%20Pack%202)-Incompatible-red.svg "SharePoint Server 2016 Feature Pack 2 requires SPFx 1.1")
![Local Workbench Unsupported](https://img.shields.io/badge/Local%20Workbench-Unsupported-red.svg "Local workbench is no longer available as of SPFx 1.13 and above")
![Hosted Workbench Compatible](https://img.shields.io/badge/Hosted%20Workbench-Compatible-green.svg)
![Compatible with Remote Containers](https://img.shields.io/badge/Remote%20Containers-Compatible-green.svg)
![Teams Yes: Designed for Microsoft Teams](https://img.shields.io/badge/Teams-Yes-green.svg "Designed for Microsoft Teams")
## Applies to
* [SharePoint Framework](https://docs.microsoft.com/sharepoint/dev/spfx/sharepoint-framework-overview)
* [Microsoft 365 tenant](https://docs.microsoft.com/sharepoint/dev/spfx/set-up-your-development-environment)
> Get your own free development tenant by subscribing to [Microsoft 365 developer program](http://aka.ms/o365devprogram)
## Solution
<!--
We use this section to recognize and promote your contributions. Please provide one author per line -- even if you worked together on it.
We'll only use the info you provided here. Make sure to include your full name, not just your GitHub username.
Provide a link to your GitHub profile to help others find more cool things you have done.
If you provide a link to your Twitter profile, we'll promote your contribution on social media.
-->
Solution|Author(s)
--------|---------
react-rhythm-of-business-calendar | [Dan Turley](https://github.com/d-turley), Avanade
## Version history
Version|Date|Comments
-------|----|--------
1.0|September 26, 2022|Initial release
## Minimal path to awesome
* Clone this repository (or [download this solution as a .ZIP file](https://pnp.github.io/download-partial/?url=https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-rhythm-of-business-calendar) then unzip it)
* From your command line, change your current directory to the directory containing this sample (`react-rhythm-of-business-calendar`, located under `samples`)
* in the command line run:
* `npm install`
* `gulp serve --nobrowser`
> This sample can also be opened with [VS Code Remote Development](https://code.visualstudio.com/docs/remote/remote-overview). Visit <https://aka.ms/spfx-devcontainer> for further instructions.
## Features
This sample is a complete app that demonstrates the "SPFx Solution Accelerator" framework, along with patterns and practices for building enterprise-class apps on SharePoint. Inspired by Domain Driven Design and Onion Architecture, this Accelerator has evolved since SPFx v1.0, and we want to share it with the world!
At a high-level, the accelerator includes the following features:
* Prescribed solution structure separates web parts, components, model, services, and schema (data) layers
* Robust entity domain model with relationships, validation, change tracking, and text search
* Robust schema provisioning and versioning; use SharePoint lists as a simple relational database
* Services for interacting with SharePoint, timezones, domain isolation, and users and groups, plus patterns for building custom services for app-specific logic
* Component library with customizable wizard, panel/dialog for quickly building view/edit screens, validation, and more
* Live Update feature ensures users are always working with the latest data without manaually reloading the page
* Built on the latest SPFx with TypeScript, React, and Fluent UI, plus PnPjs, Moment.js, Lodash, and Jest
<!--
* Prescribed [solution structure](./documentation/solution-structure.md) separates web parts, components, model, services, and schema (data) layers
* Robust [entity domain model](./documentation/entities.md) with relationships, validation, change tracking, and text search
* Robust [schema provisioning](./documentation/schema.md) and versioning; use SharePoint lists as a simple relational database
* [Services](./documentation/services.md) for interacting with SharePoint, timezones, domain isolation, and users and groups, plus patterns for building custom services for app-specific logic
* [Component library](./documentation/components.md) with customizable wizard, panel/dialog for quickly building view/edit screens, validation, and more
* [Live Update](./documentation/live-update.md) feature ensures users are always working with the latest data without manaually reloading the page
* Built on the latest SPFx with TypeScript, React, and Fluent UI, plus PnPjs, Moment.js, Lodash, and Jest
A deep dive into the various features of the accelerator can be found in the [documentation](./documentation/README.md) folder.
-->
<!--
RESERVED FOR REPO MAINTAINERS
We'll add the video from the community call recording here
## Video
[![YouTube video title](./assets/video-thumbnail.jpg)](https://www.youtube.com/watch?v=XXXXX "YouTube video title")
-->
## Help
We do not support samples, but this community is always willing to help, and we want to improve these samples. We use GitHub to track issues, which makes it easy for community members to volunteer their time and help resolve issues.
If you're having issues building the solution, please run [spfx doctor](https://pnp.github.io/cli-microsoft365/cmd/spfx/spfx-doctor/) from within the solution folder to diagnose incompatibility issues with your environment.
You can try looking at [issues related to this sample](https://github.com/pnp/sp-dev-fx-webparts/issues?q=label%3A%22sample%3A%20react-rhythm-of-business-calendar%22) to see if anybody else is having the same issues.
You can also try looking at [discussions related to this sample](https://github.com/pnp/sp-dev-fx-webparts/discussions?discussions_q=react-rhythm-of-business-calendar) and see what the community is saying.
If you encounter any issues using this sample, [create a new issue](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected%2Csample%3A%20react-rhythm-of-business-calendar&template=bug-report.yml&sample=react-rhythm-of-business-calendar&authors=@d-turley&title=react-rhythm-of-business-calendar%20-%20).
For questions regarding this sample, [create a new question](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Aquestion%2Csample%3A%20react-rhythm-of-business-calendar&template=question.yml&sample=react-rhythm-of-business-calendar&authors=@d-turley&title=react-rhythm-of-business-calendar%20-%20).
Finally, if you have an idea for improvement, [make a suggestion](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Aenhancement%2Csample%3A%20react-rhythm-of-business-calendar&template=suggestion.yml&sample=react-rhythm-of-business-calendar&authors=@d-turley&title=react-rhythm-of-business-calendar%20-%20).
## 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.**
<img src="https://pnptelemetry.azurewebsites.net/sp-dev-fx-webparts/samples/react-rhythm-of-business-calendar" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -0,0 +1,479 @@
import * as path from 'path';
import * as gulp from 'gulp';
import * as build from '@microsoft/sp-build-web';
import { IBuildConfig } from '@microsoft/gulp-core-build';
import { IPackageSolutionConfig } from "@microsoft/spfx-heft-plugins/lib/plugins/packageSolutionPlugin/SolutionPackager";
import IFeature from "@microsoft/spfx-heft-plugins/lib/plugins/packageSolutionPlugin/packageSolution/models/packageDefinition/IFeature";
import { obj as through2obj } from 'through2';
const AliasPlugin = require('enhanced-resolve/lib/AliasPlugin');
const JSON5 = require('json5');
const zip = require('gulp-zip');
const StatefulProcessCommandProxy = require('stateful-process-command-proxy');
const environmentArgName = "env";
const defaultEnvironment = "local";
interface IEnvironment {
deploySiteUrl: string;
deployScope: "Tenant" | "Site";
skipFeatureDeployment: boolean;
environmentSymbol: string;
package: {
name: string;
id: string;
filename: string;
features: {
matchLocalId: string,
title: string,
id: string,
elementManifests: string[]
}[];
};
webparts: {
manifest: string;
id: string;
alias: string;
title: string;
}[];
extensions: {
manifest: string;
id: string;
alias: string;
}[];
libraries: {
manifest: string;
id: string;
alias: string;
}[];
}
interface IEnvironmentConfiguration {
dependencies: {
spfxLibraries: string[];
nodePackages: string[];
};
environments: {
[name: string]: IEnvironment;
};
}
const environmentConfigurations = require('./environments.json') as IEnvironmentConfiguration;
const { spfxLibraries, nodePackages } = environmentConfigurations.dependencies;
const getEnvironmentConfig = (buildOptions: IBuildConfig): IEnvironment => {
return (environmentConfigurations.environments[buildOptions.args[environmentArgName] as string || defaultEnvironment]);
};
const createPowershellProxy = () => new StatefulProcessCommandProxy({
name: 'PowerShell proxy',
min: 1,
max: 1,
processCommand: 'powershell.exe',
processArgs: ['-Command', '-'],
processInvalidateOnRegex:
{
'any': [{ regex: '.*error.*', flags: 'ig' }],
'stdout': [{ regex: '.*error.*', flags: 'ig' }],
'stderr': [{ regex: '.*error.*', flags: 'ig' }]
},
logFunction: () => { }
});
const logResult = (result: any) => {
if (result.stdout) build.log(result.stdout);
if (result.stderr) build.error(result.stderr);
};
const modifyFile = (fn: (contents: string, path: string, file: any) => string) => {
return through2obj((file, enc, cb) => {
const contents = fn(String(file.contents), file.path, file) || file.contents;
if (file.isBuffer() === true) {
file.contents = Buffer.from(contents);
}
cb(null, file);
});
};
build.configureWebpack.mergeConfig({
additionalConfiguration: (generatedConfiguration) => {
const solutionFolders = ['apps', 'assets', 'common', 'components', 'domain', 'model', 'schema', 'services'];
const createPaths = (importPath: string) => {
const paths = [path.resolve(__dirname, 'lib', importPath)];
if (nodePackages) {
for (const dependency of nodePackages) {
paths.push(path.resolve(__dirname, dependency, 'lib', importPath));
}
}
return paths;
};
const resolveAliasPlugin = new AliasPlugin('described-resolve',
solutionFolders.map(folder => {
return {
name: folder,
alias: createPaths(folder)
};
}), 'resolve');
generatedConfiguration.resolve ||= {};
generatedConfiguration.resolve.plugins ||= [];
generatedConfiguration.resolve.plugins.unshift(resolveAliasPlugin);
return generatedConfiguration;
}
});
if (nodePackages) {
for (const dependency of nodePackages) {
build.serveWatchFilter.push(`${dependency}/src/**/*.{ts,tsx,scss,resx,js,json,html}`);
build.serveWatchFilter.push(`!${dependency}/src/**/*.{scss.ts,resx.ts}`);
}
}
if (spfxLibraries) {
for (const dependency of spfxLibraries) {
build.serveWatchFilter.push(`${dependency}/dist/*.{json}`);
}
}
const buildDependency_NodePackages_Subtask = build.subTask('build-dependency-nodepackages-subtask', async (_gulp, buildOptions) => {
if (nodePackages) {
for (const nodePackage of nodePackages) {
const powershellProxy = createPowershellProxy();
const gulpCommand = `cmd.exe /c "cd ${nodePackage} && gulp build ${buildOptions.production ? '--production' : ''}"`;
build.log("Executing ", gulpCommand);
await powershellProxy.executeCommand(gulpCommand).then(logResult);
await powershellProxy.shutdown();
}
}
return {};
});
build.task('build-dependency-nodepackages', buildDependency_NodePackages_Subtask);
const modifyPackageSolutionJsonSubtask = build.subTask('modify-package-solution-json-subtask', (_gulp, buildOptions, done) => {
const config = getEnvironmentConfig(buildOptions);
if (config) {
return gulp.src('config/package-solution.json', { base: '.' })
.pipe(gulp.dest(buildOptions.tempFolder + '/perEnvConfig'))
.pipe(modifyFile((content: string) => {
build.log("Modifying package-solution.json");
const json = JSON5.parse(content) as IPackageSolutionConfig;
json.solution!.id = config.package.id;
json.solution!.name = config.package.name;
json.solution!.features = json.solution!.features && json.solution!.features.map(originalFeature => {
const configFeature = (config.package.features || []).filter(f => f.matchLocalId == originalFeature.id)[0];
if (configFeature) {
const modifiedFeature: IFeature = Object.assign({}, originalFeature);
modifiedFeature.id = configFeature.id;
modifiedFeature.title = configFeature.title;
modifiedFeature.assets!.elementManifests = configFeature.elementManifests;
return modifiedFeature;
} else {
return originalFeature;
}
});
json.paths!.zippedPackage = `solution/${config.package.filename}`;
return JSON.stringify(json, undefined, 4);
}))
.pipe(gulp.dest('.'));
}
else {
done!();
}
});
const modifyWebPartManifestsSubtask = build.subTask('modify-webpart-manifests-subtask', (_gulp, buildOptions, done) => {
const config = getEnvironmentConfig(buildOptions);
if (config) {
return gulp.src('src/webparts/*/*WebPart.manifest.json', { base: '.' })
.pipe(gulp.dest(buildOptions.tempFolder + '/perEnvConfig'))
.pipe(modifyFile((content: string, filePath: string) => {
const filename = filePath.split('\\').pop()?.split('/').pop()!;
const webpartConfig = config.webparts.filter(wp => wp.manifest == filename)[0];
build.log("Examining ", filename);
if (webpartConfig) {
build.log("Modifying ", filename);
const json = JSON5.parse(content);
json.id = webpartConfig.id;
json.alias = webpartConfig.alias;
json.preconfiguredEntries[0].title.default = webpartConfig.title;
return JSON.stringify(json, undefined, 4);
} else {
build.log("No modifications specified ", filename);
return content;
}
}))
.pipe(gulp.dest('.'));
} else {
done!();
}
});
const modifyExtensionManifestsSubtask = build.subTask('modify-extension-manifests-subtask', (_gulp, buildOptions, done) => {
const config = getEnvironmentConfig(buildOptions);
if (config && config.extensions) {
return gulp.src('src/extensions/*/*ApplicationCustomizer.manifest.json', { base: '.' })
.pipe(gulp.dest(buildOptions.tempFolder + '/perEnvConfig'))
.pipe(modifyFile((content: string, filePath: string) => {
const filename = filePath.split('\\').pop()?.split('/').pop()!;
const extensionConfig = config.extensions.filter(ext => ext.manifest == filename)[0];
build.log("Examining ", filename);
if (extensionConfig) {
build.log("Modifying ", filename);
const json = JSON5.parse(content);
json.id = extensionConfig.id;
json.alias = extensionConfig.alias;
return JSON.stringify(json, undefined, 4);
} else {
build.log("No modifications specified ", filename);
return content;
}
}))
.pipe(gulp.dest('.'));
} else {
done!();
}
});
const modifyLibraryManifestsSubtask = build.subTask('modify-library-manifests-subtask', (_gulp, buildOptions, done) => {
const config = getEnvironmentConfig(buildOptions);
if (config) {
return gulp.src('src/**/*Library.manifest.json', { base: '.' })
.pipe(gulp.dest(buildOptions.tempFolder + '/perEnvConfig'))
.pipe(modifyFile((content: string, filePath: string) => {
const filename = filePath.split('\\').pop()?.split('/').pop()!;
const libraryConfig = config.libraries.filter(wp => wp.manifest == filename)[0];
build.log("Examining ", filename);
if (libraryConfig) {
build.log("Modifying ", filename);
const json = JSON5.parse(content);
json.id = libraryConfig.id;
json.alias = libraryConfig.alias;
return JSON.stringify(json, undefined, 4);
} else {
build.log("No modifications specified ", filename);
return content;
}
}))
.pipe(gulp.dest('.'));
} else {
done!();
}
});
const modifySchemaDefaultsSubtask = build.subTask('modify-schema-defaults-subtask', (_gulp, buildOptions, done) => {
const config = getEnvironmentConfig(buildOptions);
if (config) {
return gulp.src('src/**{,/*/**}/Defaults.ts', { base: '.' })
.pipe(gulp.dest(buildOptions.tempFolder + '/perEnvConfig'))
.pipe(modifyFile((content: string, filePath: string) => {
build.log("Modifying ", filePath.split('\\src\\').pop()!);
const lines = content.split('\n');
return lines.map(line => {
if (line.startsWith("const Environment ="))
return `const Environment = ${config.environmentSymbol};`;
else
return line;
}).join('\n');
}))
.pipe(gulp.dest('.'));
} else {
done!();
}
});
const modifySchemaDefaults_NodePackages_Subtask = build.subTask('modify-schema-defaults-nodepackages-subtask', (_gulp, buildOptions, done) => {
const config = getEnvironmentConfig(buildOptions);
if (config && nodePackages) {
return Promise.all(nodePackages.map(nodePackage => {
return new Promise<void>((resolve, reject) => {
gulp.src(`${nodePackage}/src/**{,/*/**}/Defaults.ts`, { base: `./${nodePackage}` })
.pipe(gulp.dest(`${nodePackage}/temp/perEnvConfig`))
.on('error', reject)
.pipe(modifyFile((content: string, filePath: string) => {
build.log("Modifying ", nodePackage, ' ', filePath.split('\\src\\').pop()!);
const lines = content.split('\n');
return lines.map(line => {
if (line.startsWith("const Environment ="))
return `const Environment = ${config.environmentSymbol};`;
else
return line;
}).join('\n');
}))
.pipe(gulp.dest(`./${nodePackage}`))
.on('end', resolve);
});
}));
} else {
done!();
}
});
const modifyConfig_SPFxLibraries_Subtask = build.subTask('modify-config-spfxlibs-subtask', async (_gulp, buildOptions) => {
if (spfxLibraries) {
for (const spfxLibrary of spfxLibraries) {
const powershellProxy = createPowershellProxy();
const gulpCommand = `cmd.exe /c "cd ${spfxLibrary} && gulp modify-env-config --env ${buildOptions.args[environmentArgName]}"`;
build.log("Executing ", gulpCommand);
await powershellProxy.executeCommand(gulpCommand).then(logResult);
await powershellProxy.shutdown();
}
}
return {};
});
const restoreConfig_Subtask = build.subTask('restore-config-subtask', (_gulp, buildOptions) => {
return gulp
.src(buildOptions.tempFolder + '/perEnvConfig/**/*')
.pipe(gulp.dest('.'));
});
const restoreDependencyConfig_NodePackages_Subtask = build.subTask('restore-dependency-config-nodepackages-subtask', (_gulp, _buildOptions, done) => {
if (nodePackages) {
return Promise.all(nodePackages.map(nodePackage => {
return new Promise<void>((resolve, reject) => {
return gulp
.src(`${nodePackage}/temp/perEnvConfig/**/*`)
.on('error', reject)
.pipe(gulp.dest(`./${nodePackage}`))
.on('end', resolve);
});
}));
} else {
done!();
}
});
const restoreDependencyConfig_SPFxLibraries_Subtask = build.subTask('restore-dependency-config-spfxlibs-subtask', async (_gulp, _buildOptions) => {
if (spfxLibraries) {
for (const spfxLibrary of spfxLibraries) {
const powershellProxy = createPowershellProxy();
const gulpCommand = `cmd.exe /c "cd ${spfxLibrary} && gulp restore-original-config"`;
build.log("Executing ", gulpCommand);
await powershellProxy.executeCommand(gulpCommand).then(logResult);
await powershellProxy.shutdown();
}
}
return {};
});
const zipSourceCodeSubtask = build.subTask('zip-sourcecode-subtask', (_gulp, buildOptions, done) => {
const config = getEnvironmentConfig(buildOptions);
if (config) {
return gulp.src(['*.*', 'build/**/*.*', 'config/**/*.*', 'mock_modules/**/*.*', 'release/assets/**/*.*', 'src/**{,/*/**}/*.*'], { base: '.' })
.pipe(zip(`${config.package.filename.replace('.sppkg', '')}-src.zip`))
.pipe(gulp.dest('sharepoint/solution'));
} else {
done!();
}
});
build.task('zip-sourcecode', zipSourceCodeSubtask);
const publishSolutionSubtask = build.subTask('publish-solution-subtask', async (_gulp, buildOptions) => {
const config = getEnvironmentConfig(buildOptions);
if (config && config.deploySiteUrl) {
const powershellProxy = createPowershellProxy();
const scope = config.deployScope || "Site";
const connectCommand = `Connect-PnPOnline -Interactive -Url ${config.deploySiteUrl}`;
const addPackageCommand = `Add-PnPApp -Path .\\sharepoint\\solution\\${config.package.filename} -Scope ${scope} -Publish -Overwrite ${config.skipFeatureDeployment ? '-SkipFeatureDeployment' : ''}`;
build.log("Executing ", connectCommand);
await powershellProxy.executeCommand(connectCommand).then(logResult);
build.log("Executing ", addPackageCommand);
await powershellProxy.executeCommand(addPackageCommand).then(logResult);
await powershellProxy.shutdown();
}
return {};
});
build.task('publish', publishSolutionSubtask);
const modifyEnvConfigTask = build.task('modify-env-config',
build.parallel(
modifyConfig_SPFxLibraries_Subtask,
modifyPackageSolutionJsonSubtask,
modifySchemaDefaults_NodePackages_Subtask,
modifySchemaDefaultsSubtask,
modifyWebPartManifestsSubtask,
modifyExtensionManifestsSubtask,
modifyLibraryManifestsSubtask
)
);
const restoreOriginalConfigTask = build.task('restore-original-config',
build.parallel(
restoreDependencyConfig_SPFxLibraries_Subtask,
restoreDependencyConfig_NodePackages_Subtask,
restoreConfig_Subtask
)
);
build.rig.addPreBuildTask(buildDependency_NodePackages_Subtask);
const buildTask = build.serial(
build.preCopy,
buildDependency_NodePackages_Subtask,
build.parallel(build.sass, build.copyStaticAssets),
build.parallel(build.lintCmd, build.tscCmd),
build.postCopy
);
const bundleTask = build.serial(
buildTask,
build.configureWebpack,
build.webpack
);
const packageTask = build.task('package',
build.serial(
build.clean,
modifyEnvConfigTask,
bundleTask,
zipSourceCodeSubtask,
build.packageSolution,
restoreOriginalConfigTask
)
);
build.task('deploy',
build.serial(
packageTask,
publishSolutionSubtask
)
);
build.task('serve',
build.serial(
build.serve,
build.watch(build.serveWatchFilter,
build.serial(
bundleTask,
build.reload
)
)
)
);
build.addSuppression(/^Warning - \[sass\].*$/);
build.initialize(gulp);

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"rhythm-of-business-web-parts": {
"components": [
{
"entrypoint": "./lib/webparts/rhythmOfBusinessCalendar/RhythmOfBusinessCalendarWebPart.js",
"manifest": "./src/webparts/rhythmOfBusinessCalendar/RhythmOfBusinessCalendarWebPart.manifest.json"
}
]
}
},
"externals": { },
"localizedResources": {
"CommonStrings": "lib/common/components/loc/{locale}.js",
"ComponentStrings": "lib/components/loc/{locale}.js",
"RhythmOfBusinessCalendarWebPartStrings": "lib/webparts/rhythmOfBusinessCalendar/loc/{locale}.js"
}
}

View File

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

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./release/assets/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "rhythm-of-business-calendar",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "LOCAL Rhythm of Business Calendar",
"id": "37df9a1c-b53e-46ad-9efb-2e4da77a724f",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"developer": {
"name": "",
"websiteUrl": "",
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": ""
}
},
"paths": {
"zippedPackage": "solution/RhythmOfBusinessCalendar-LOCAL.sppkg"
}
}

View File

@ -0,0 +1,6 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://contoso.sharepoint.com/_layouts/15/workbench.aspx"
}

View File

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

View File

@ -0,0 +1,17 @@
# SPFx Solution Accelerator Deep Dive
## Solution Structure
## Build Tools
## Entities
## Services
## Schema
## Components
## Live Update
## Fast Load Caching

View File

@ -0,0 +1,3 @@
# Build Tools
Coming soon

View File

@ -0,0 +1,3 @@
# Components
Coming soon

View File

@ -0,0 +1,3 @@
# Entities
Coming soon

View File

@ -0,0 +1,3 @@
# Fast Load Caching
Coming soon

View File

@ -0,0 +1,3 @@
# Live Update
Coming soon

View File

@ -0,0 +1,3 @@
# Schema
Coming soon

View File

@ -0,0 +1,3 @@
# Services
Coming soon

View File

@ -0,0 +1,3 @@
# Solution Structure
Coming soon

View File

@ -0,0 +1,98 @@
{
"dependencies": {},
"environments": {
"local": {
"environmentSymbol": "Environments.LOCAL",
"skipFeatureDeployment": false,
"package": {
"name": "LOCAL Rhythm of Business Calendar",
"id": "37df9a1c-b53e-46ad-9efb-2e4da77a724f",
"filename": "RhythmOfBusinessCalendar-LOCAL.sppkg"
},
"webparts": [
{
"manifest": "RhythmOfBusinessCalendarWebPart.manifest.json",
"id": "ff77b45a-483c-4fe7-94b4-b5fc8def29c0",
"alias": "RhythmOfBusinessCalendarWebPartLOCAL",
"title": "(LOCAL) Rhythm of Business Calendar"
}
]
},
"dev": {
"deploySiteUrl": "https://contoso.sharepoint.com/sites/RhythmOfBusinessCalendar_DEV",
"deployScope": "Site",
"skipFeatureDeployment": false,
"environmentSymbol": "Environments.DEV",
"package": {
"name": "DEV Rhythm of Business Calendar",
"id": "54b89a4f-15c4-4007-942f-d1d9e8fc6871",
"filename": "RhythmOfBusinessCalendar-DEV.sppkg"
},
"webparts": [
{
"manifest": "RhythmOfBusinessCalendarWebPart.manifest.json",
"id": "8454e333-242f-45af-bd58-bd823010822a",
"alias": "RhythmOfBusinessCalendarWebPartDEV",
"title": "(DEV) Rhythm of Business Calendar"
}
]
},
"test": {
"deploySiteUrl": "https://contoso.sharepoint.com/sites/appcatalog",
"deployScope": "Tenant",
"skipFeatureDeployment": true,
"environmentSymbol": "Environments.TEST",
"package": {
"name": "TEST Rhythm of Business Calendar",
"id": "839937b0-a90a-4665-8ed6-30dcde0cfa9e",
"filename": "RhythmOfBusinessCalendar-TEST.sppkg"
},
"webparts": [
{
"manifest": "RhythmOfBusinessCalendarWebPart.manifest.json",
"id": "0039f049-a62f-427e-830e-7b3de838f69e",
"alias": "RhythmOfBusinessCalendarWebPartTEST",
"title": "(TEST) Rhythm of Business Calendar"
}
]
},
"stage": {
"deploySiteUrl": "https://contoso.sharepoint.com/sites/appcatalog",
"deployScope": "Tenant",
"skipFeatureDeployment": true,
"environmentSymbol": "Environments.STAGE",
"package": {
"name": "STG Rhythm of Business Calendar",
"id": "bf1f3d59-8ce2-4c98-9c92-a7a7bf36abb6",
"filename": "RhythmOfBusinessCalendar-STG.sppkg"
},
"webparts": [
{
"manifest": "RhythmOfBusinessCalendarWebPart.manifest.json",
"id": "b7aeb8c7-7819-4f06-8779-00dce2caeeab",
"alias": "RhythmOfBusinessCalendarWebPartSTG",
"title": "(STG) Rhythm of Business Calendar"
}
]
},
"prod": {
"deploySiteUrl": "https://contoso.sharepoint.com/sites/appcatalog",
"deployScope": "Tenant",
"skipFeatureDeployment": true,
"environmentSymbol": "Environments.PROD",
"package": {
"name": "Rhythm of Business Calendar",
"id": "8588904e-d33f-40ac-9ee0-2fcfcfb0ebc2",
"filename": "RhythmOfBusinessCalendar.sppkg"
},
"webparts": [
{
"manifest": "RhythmOfBusinessCalendarWebPart.manifest.json",
"id": "077f5c4a-37bf-4531-ac24-6369ca0f5f51",
"alias": "RhythmOfBusinessCalendarWebPart",
"title": "Rhythm of Business Calendar"
}
]
}
}
}

View File

@ -0,0 +1 @@
eval(require("typescript").transpile(require("fs").readFileSync("build/gulpfile.ts").toString()));

View File

@ -0,0 +1,41 @@
const { defaults: tsjPreset } = require('ts-jest/presets');
module.exports = {
"rootDir": ".",
"collectCoverage": true,
"coverageDirectory": "temp/test",
"coverageReporters": [
"json",
"lcov",
"text-summary"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"json"
],
"moduleNameMapper": {
"\\.(css|scss)$": "identity-obj-proxy",
"^@microsoft/sp-core-library": "identity-obj-proxy",
"^resx-strings/en-us.json": "@microsoft/sp-core-library/lib/resx-strings/en-us.json"
},
"moduleDirectories": [
"mock_modules",
"mock_loc_modules",
"src",
"node_modules"
],
"setupFiles": [
"raf/polyfill"
],
"globalSetup": "./jest.setup.localization-mocks.ts",
"snapshotSerializers": [],
"testMatch": [
"**/src/**/*.(spec|test).+(ts|js)?(x)"
],
"testURL": "http://localhost",
"transform": {
...tsjPreset.transform,
}
};

View File

@ -0,0 +1,61 @@
import fs from "fs";
import path from "path";
declare const __dirname: string;
declare global {
namespace NodeJS {
interface Global {
define: any;
}
}
}
module.exports = async () => {
const mockModulesPath = "mock_loc_modules";
const config = JSON.parse(fs.readFileSync("./config/config.json").toString());
const packageJson = (stringModule: string) =>
`{"name":"${stringModule}","main":"index.js"}`;
const rel = (pathString: string) => path.join(__dirname, ...pathString.split("/"));
if (!fs.existsSync(rel(`${mockModulesPath}`))) {
fs.mkdirSync(rel(`${mockModulesPath}`));
}
Object.keys(config.localizedResources).forEach((stringModule: string) => {
if (!fs.existsSync(rel(`${mockModulesPath}/${stringModule}`))) {
fs.mkdirSync(rel(`${mockModulesPath}/${stringModule}`));
}
// try to get strings - check various combinations until the file is found
let stringsPath = config.localizedResources[stringModule].replace(
"{locale}",
"en-us"
);
if (!fs.existsSync(rel(stringsPath)))
stringsPath = stringsPath.replace("lib", "src");
if (!fs.existsSync(rel(stringsPath)))
stringsPath = stringsPath.replace("en-us", "en_us");
if (!fs.existsSync(rel(stringsPath)))
stringsPath = stringsPath.replace("src", "lib");
// set requirejs define function
global.define = (name: string, ready: Function): void => {
fs.writeFileSync(
rel(`${mockModulesPath}/${stringModule}/index.js`),
"module.exports = " + JSON.stringify(ready(), null, 2)
);
};
require(rel(stringsPath).replace(/\.js$/, ""));
fs.writeFileSync(
rel(`${mockModulesPath}/${stringModule}/package.json`),
packageJson(stringModule)
);
});
};

View File

@ -0,0 +1,20 @@
export type SPHttpClientConfiguration = any;
export type ISPHttpClientOptions = any;
export type SPHttpClientResponse = any;
export class SPHttpClient {
public static readonly configurations: any;
public async fetch(url: string, configuration: SPHttpClientConfiguration, options: ISPHttpClientOptions): Promise<SPHttpClientResponse> {
return null;
}
public async get(url: string, configuration: SPHttpClientConfiguration, options?: ISPHttpClientOptions): Promise<SPHttpClientResponse> {
return null;
}
public async post(url: string, configuration: SPHttpClientConfiguration, options: ISPHttpClientOptions): Promise<SPHttpClientResponse> {
return null;
}
}

View File

@ -0,0 +1,25 @@
export abstract class BaseWebPart<TProperties extends {}> {
protected readonly dataVersion: any; //Version;
protected readonly properties: TProperties;
protected readonly disableReactivePropertyChanges: boolean;
protected readonly previewImageUrl: string | undefined;
protected readonly accessibleTitle: string;
protected readonly title: string;
protected readonly description: string;
constructor() { }
protected async onInit(): Promise<void> { }
protected getPropertyPaneConfiguration(): any { return null; } //IPropertyPaneConfiguration;
}
export abstract class BaseClientSideWebPart<TProperties> extends BaseWebPart<TProperties> {
protected readonly context: any; //WebPartContext;
protected readonly domElement: HTMLElement;
constructor() { super(); }
protected abstract render(): void;
protected onDispose(): void { }
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
{
"name": "rhythm-of-business-calendar",
"version": "1.0.0",
"main": "lib/index.js",
"private": true,
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"deploy": "gulp deploy",
"package": "gulp package",
"test": "./node_modules/.bin/jest"
},
"resolutions": {
"@types/react": "~16.9.51"
},
"dependencies": {
"@fluentui/react": "^8.77.2",
"@fluentui/react-hooks": "^8.6.0",
"@fluentui/react-icons-mdl2": "^1.3.11",
"@microsoft/sp-component-base": "^1.15.2",
"@microsoft/sp-core-library": "^1.15.2",
"@microsoft/sp-http": "^1.15.2",
"@microsoft/sp-list-subscription": "^1.15.2",
"@microsoft/sp-loader": "^1.15.2",
"@microsoft/sp-property-pane": "1.15.2",
"@microsoft/sp-webpart-base": "1.15.2",
"@pnp/common": "^2.13.0",
"@pnp/graph": "^2.13.0",
"@pnp/logging": "^2.13.0",
"@pnp/odata": "^2.13.0",
"@pnp/sp": "^2.13.0",
"compressed-json": "^1.0.16",
"he": "^1.2.0",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"moment-timezone": "^0.5.34",
"office-ui-fabric-react": "7.185.7",
"react": "~16.13.1",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "~16.13.1",
"react-router-dom": "^6.3.0",
"sanitize-html": "^2.7.1",
"swiped-events": "^1.1.6"
},
"devDependencies": {
"@microsoft/eslint-config-spfx": "^1.15.2",
"@microsoft/eslint-plugin-spfx": "^1.15.2",
"@microsoft/gulp-core-build": "3.17.19",
"@microsoft/rush-stack-compiler-4.5": "0.2.2",
"@microsoft/sp-build-core-tasks": "^1.15.2",
"@microsoft/sp-build-web": "^1.15.2",
"@microsoft/sp-module-interfaces": "^1.15.2",
"@rushstack/eslint-config": "2.5.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.2.7",
"@types/he": "^1.1.2",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/jest": "^26.0.24",
"@types/react": "~16.9.51",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "~16.9.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-test-renderer": "~16.9.5",
"@types/sanitize-html": "^2.3.2",
"@types/sharepoint": "^2016.1.10",
"@types/webpack-env": "^1.16.2",
"acorn": "^8.4.1",
"ajv": "^8.6.1",
"enhanced-resolve": "^5.8.2",
"eslint-plugin-react-hooks": "^4.3.0",
"gulp": "^4.0.2",
"gulp-zip": "^5.1.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"json5": "^2.2.0",
"raf": "^3.4.1",
"react-test-renderer": "~16.13.1",
"stateful-process-command-proxy": "^1.0.1",
"ts-jest": "^26.5.6"
},
"engines": {
"node": ">=16.15.1"
}
}

View File

@ -0,0 +1,250 @@
import React, { memo } from "react";
import { ShimmerElementsGroup, ShimmerElementType, Stack, StackItem } from "@fluentui/react";
export const LoadingShimmer = memo(() => {
if (window.outerWidth >= 640) {
return (
<Stack horizontal>
<StackItem>
<ShimmerElementsGroup shimmerElements={[
// refiner rail
{ type: ShimmerElementType.gap, width: 20, height: 456 }, // outer margin
{ type: ShimmerElementType.line, width: 150, height: 444, verticalAlign: 'bottom' },
]} />
</StackItem>
<StackItem grow>
<ShimmerElementsGroup width="100%" shimmerElements={[
// command bar
{ type: ShimmerElementType.gap, width: 20, height: 48 + 12 }, // outer margin
{ type: ShimmerElementType.line, width: '25%', height: 32 },
{ type: ShimmerElementType.gap, width: '75%', height: 1 },
{ type: ShimmerElementType.gap, width: 20, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// date rotator and view nav
{ type: ShimmerElementType.gap, width: 20, height: 32 + 12 }, // outer margin
{ type: ShimmerElementType.line, width: '15%', height: 32 },
{ type: ShimmerElementType.gap, width: '60%', height: 1 },
{ type: ShimmerElementType.line, width: '25%', height: 32 },
{ type: ShimmerElementType.gap, width: 20, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// weekdays header
{ type: ShimmerElementType.gap, width: 20, height: 16 + 12 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 20, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// week 1
{ type: ShimmerElementType.gap, width: 20, height: 75 + 6 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// week 2
{ type: ShimmerElementType.gap, width: 20, height: 75 + 6 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// week 3
{ type: ShimmerElementType.gap, width: 20, height: 75 + 6 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// week 4
{ type: ShimmerElementType.gap, width: 20, height: 75 + 6 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 20, height: 1 } // outer margin
]} />
</StackItem>
</Stack>
);
} else {
return (
<Stack horizontal>
<StackItem>
<ShimmerElementsGroup shimmerElements={[
// refiner rail
{ type: ShimmerElementType.gap, width: 10, height: 448 }, // outer margin
{ type: ShimmerElementType.line, width: 40, height: 440, verticalAlign: 'bottom' },
]} />
</StackItem>
<StackItem grow>
<ShimmerElementsGroup width="100%" shimmerElements={[
// command bar
{ type: ShimmerElementType.gap, width: 10, height: 48 + 12 }, // outer margin
{ type: ShimmerElementType.line, width: '90%', height: 32 },
{ type: ShimmerElementType.gap, width: '10%', height: 1 },
{ type: ShimmerElementType.gap, width: 10, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// date rotator
{ type: ShimmerElementType.gap, width: 10, height: 32 + 12 }, // outer margin
{ type: ShimmerElementType.line, width: '50%', height: 32 },
{ type: ShimmerElementType.gap, width: '50%', height: 1 },
{ type: ShimmerElementType.gap, width: 10, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// view nav
{ type: ShimmerElementType.gap, width: 10, height: 32 + 12 }, // outer margin
{ type: ShimmerElementType.line, width: '75%', height: 32 },
{ type: ShimmerElementType.gap, width: '25%', height: 1 },
{ type: ShimmerElementType.gap, width: 10, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// weekdays header
{ type: ShimmerElementType.gap, width: 10, height: 16 + 12 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 16 },
{ type: ShimmerElementType.gap, width: 10, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// week 1
{ type: ShimmerElementType.gap, width: 10, height: 75 + 4 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 10, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// week 2
{ type: ShimmerElementType.gap, width: 10, height: 75 + 4 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 10, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// week 3
{ type: ShimmerElementType.gap, width: 10, height: 75 + 4 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 10, height: 1 } // outer margin
]} />
<ShimmerElementsGroup width="100%" shimmerElements={[
// week 4
{ type: ShimmerElementType.gap, width: 10, height: 75 + 4 }, // outer margin
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 5, height: 1 },
{ type: ShimmerElementType.line, width: '14%', height: 75 },
{ type: ShimmerElementType.gap, width: 10, height: 1 } // outer margin
]} />
</StackItem>
</Stack>
);
}
});

View File

@ -0,0 +1,98 @@
import React, { Component, ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { BaseClientSideWebPart } from "@microsoft/sp-webpart-base";
import { SharePointApp } from "common/components";
import { Root, ConfigurationWizard, Upgrade } from 'components';
import {
ServicesType,
DeveloperServiceDescriptor, DirectoryServiceDescriptor, TimeZoneServiceDescriptor, SharePointServiceDescriptor, LiveUpdateServiceDescriptor,
ConfigurationServiceDescriptor, EventsServiceDescriptor,
SharePointService, ConfigurationService, DeveloperService, LiveUpdateService
} from "services";
import { LoadingShimmer } from "./LoadingShimmer";
const AppServiceDescriptors = [
DeveloperServiceDescriptor,
TimeZoneServiceDescriptor,
DirectoryServiceDescriptor,
SharePointServiceDescriptor,
LiveUpdateServiceDescriptor,
ConfigurationServiceDescriptor,
EventsServiceDescriptor
];
type AppServices = ServicesType<typeof AppServiceDescriptors>;
interface IProps {
webpart: BaseClientSideWebPart<any>;
}
class RhythmOfBusinessCalendarApp extends Component<IProps> {
constructor(props: IProps) {
super(props);
}
private readonly _onAfterInitServices = async (services: AppServices) => {
const {
[SharePointService]: spo,
[LiveUpdateService]: liveUpdate
} = services;
await spo.preflightSchema();
liveUpdate.begin();
this._registerDevScripts(services);
}
private readonly _registerDevScripts = async (services: AppServices) => {
const { [DeveloperService]: dev } = services;
dev.registerScripts({
debug: {
}
});
}
private readonly _renderApp = (services: AppServices) => {
const {
[ConfigurationService]: configurations
} = services;
const requiresUpgrade = configurations.active?.schemaRequiresUpgrade;
if (!configurations.active) {
return <ConfigurationWizard onSetupComplete={() => this.forceUpdate()} />;
} else if (requiresUpgrade) {
return <Upgrade onUpgradeComplete={() => window.location.reload()} />;
} else {
return (
<MemoryRouter>
<Root />
</MemoryRouter>
);
}
}
public render(): ReactElement<IProps> {
const { webpart } = this.props;
return (
<SharePointApp
appName="RhythmOfBusinessCalendar"
companyName="Contoso"
spfxComponent={webpart}
spfxContext={webpart.context}
teams={webpart.context.sdks.microsoftTeams}
serviceDescriptors={AppServiceDescriptors}
onInitAfterServices={this._onAfterInitServices}
shimmerElements={<LoadingShimmer />}
>
{this._renderApp}
</SharePointApp>
);
}
}
export default RhythmOfBusinessCalendarApp;

View File

@ -0,0 +1 @@
export { default as RhythmOfBusinessCalendarApp } from './RhythmOfBusinessCalendarApp';

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,180 @@
import { flatten } from "lodash";
import { IComponent } from "./IComponent";
export interface IAsyncData<T> {
done: boolean;
loaded: boolean;
saving: boolean;
error: any;
data: T;
promise: Promise<T>;
registerComponentForUpdates(component: IComponent): void;
unregisterComponentForUpdates(component: IComponent): void;
}
export abstract class AsyncDataBase<T> implements IAsyncData<T> {
private readonly _registeredComponents: Set<IComponent>;
constructor() {
this._registeredComponents = new Set();
}
public abstract done: boolean;
public abstract saving: boolean;
public abstract error: any;
public abstract data: T;
public abstract promise: Promise<T>;
public get loaded(): boolean {
return this.done && !this.error;
}
public registerComponentForUpdates(component: IComponent) {
this._registeredComponents.add(component);
}
public unregisterComponentForUpdates(component: IComponent) {
this._registeredComponents.delete(component);
}
protected notifyHandlers() {
this._registeredComponents.forEach(component => component.componentShouldRender());
}
}
export class AsyncData<T, K = any> extends AsyncDataBase<T> {
public static createWithData<T, K = any>(data?: T, key?: K) {
const dataAsync = new AsyncData<T, K>(key);
dataAsync.dataLoaded(data);
return dataAsync;
}
public static createWithError<T, K = any>(error: any, key?: K) {
const dataAsync = new AsyncData<T, K>(key);
dataAsync.dataFailed(error);
return dataAsync;
}
private _key: K;
public get key(): K { return this._key; }
public done: boolean;
public saving: boolean;
public error: any;
public data: T;
public readonly promise: Promise<T>;
private _promiseResolveFn: (d: T) => void;
private _promiseRejectFn: (e: any) => void;
constructor(key?: K, promise?: Promise<T>) {
super();
this._key = key;
this.done = false;
this.error = null;
this.saving = false;
this.data = null;
this.promise = new Promise<T>((resolve, reject) => { this._promiseResolveFn = resolve; this._promiseRejectFn = reject; });
promise?.then(this.dataLoaded, this.saveFailed);
}
public readonly dataLoaded = (data?: T) => {
if (!this.done) {
this.data = data || this.data;
this.done = true;
this._promiseResolveFn(this.data);
this.notifyHandlers();
}
}
public readonly dataFailed = (error: any) => {
console.error(error);
this.error = error;
this.done = true;
this._promiseRejectFn(this.error);
this.notifyHandlers();
}
public readonly savingStarted = () => {
this.error = null;
this.saving = true;
this.notifyHandlers();
}
public readonly saveSuccessful = () => {
this.error = null;
this.saving = false;
this.notifyHandlers();
}
public readonly saveFailed = (error: any) => {
console.error(error);
this.error = error;
this.saving = false;
this.notifyHandlers();
}
public readonly dataUpdated = () => {
this.notifyHandlers();
}
public replaceKey(key: K) {
this._key = key;
}
}
export class AggregatedAsyncData<T> extends AsyncDataBase<T[]> {
private readonly _dataAsync: IAsyncData<T | readonly T[]>[];
public readonly promise: Promise<T[]>;
constructor(dataAsync: IAsyncData<T | readonly T[]>[]) {
super();
this._dataAsync = [...dataAsync];
this.promise = Promise.all(this._dataAsync.map(d => d.promise)).then(flatten);
}
public get done(): boolean {
return this._dataAsync.every(async => async.done);
}
public get error() {
return this._dataAsync.filter(async => async.error)[0]?.error;
}
public get loaded(): boolean {
return this._dataAsync.every(async => async.loaded);
}
public get saving(): boolean {
return this._dataAsync.some(async => async.saving);
}
public get data(): T[] {
return flatten(this._dataAsync.map(async => async.data instanceof Array ? async.data : [async.data]));
}
public registerComponentForUpdates(component: IComponent) {
this._dataAsync.forEach(async => async.registerComponentForUpdates(component));
}
public unregisterComponentForUpdates(component: IComponent) {
this._dataAsync.forEach(async => async.unregisterComponentForUpdates(component));
}
}
export class AsyncDataCache<T> {
private _dataAsync: AsyncData<T>;
constructor(
private materialize: () => Promise<T>,
private key?: string
) { }
public get(): AsyncData<T> {
return this._dataAsync = (this._dataAsync || new AsyncData<T>(this.key, this.materialize()));
}
}

View File

@ -0,0 +1,34 @@
import { Guid } from "@microsoft/sp-core-library";
export class BackEventListener {
private readonly _historyStateInstance = { id: Guid.newGuid().toString() };
constructor(
private readonly _onBackPressed: () => void
) {
window.addEventListener('popstate', this._popStateHandler);
}
public cleanup() {
window.removeEventListener('popstate', this._popStateHandler);
}
private readonly _popStateHandler = (ev: PopStateEvent) => {
this._onBackPressed();
}
public listenForBack() {
if (!this._isCurrentHistoryState()) {
window.history.pushState(this._historyStateInstance, '');
}
}
public cancelListeningForBack() {
if (this._isCurrentHistoryState())
window.history.back();
}
private _isCurrentHistoryState(): boolean {
return window.history.state?.id === this._historyStateInstance.id;
}
}

View File

@ -0,0 +1,77 @@
import { clamp, padStart } from "lodash";
export class Color {
public static parse(val: string): Color {
const red = parseInt(val.substring(1, 3), 16);
const green = parseInt(val.substring(3, 5), 16);
const blue = parseInt(val.substring(5, 7), 16);
return new Color(red, green, blue, 1);
}
private _alpha: number;
private _red: number;
private _green: number;
private _blue: number;
constructor(red: number, green: number, blue: number, alpha: number = 1.0) {
this.alpha = alpha;
this.red = red;
this.green = green;
this.blue = blue;
}
public clone(): Color {
return new Color(this.red, this.green, this.blue, this.alpha);
}
public get alpha(): number { return this._alpha; }
public set alpha(val: number) { this._alpha = clamp(val, 0.0, 1.0); }
public get red(): number { return this._red; }
public set red(val: number) { this._red = clamp(val, 0, 255); }
public get green(): number { return this._green; }
public set green(val: number) { this._green = clamp(val, 0, 255); }
public get blue(): number { return this._blue; }
public set blue(val: number) { this._blue = clamp(val, 0, 255); }
public opacity(val: number): this {
this.alpha = val;
return this;
}
public lighten(val: number): Color {
if (val === 0 || isNaN(val)) return this.clone();
let { red, green, blue } = this;
red += Math.round((255 - red) * val);
green += Math.round((255 - green) * val);
blue += Math.round((255 - blue) * val);
return new Color(red, green, blue, this.alpha);
}
public darken(val: number): Color {
if (val === 0 || isNaN(val)) return this.clone();
let { red, green, blue } = this;
red = Math.round(red * (1 - val));
green = Math.round(green * (1 - val));
blue = Math.round(blue * (1 - val));
return new Color(red, green, blue, this.alpha);
}
public toCssString(): string {
return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`;
}
public toHexString(): string {
const toHexComponent = (val: number) => padStart(val.toString(16), 2, '0');
const red = toHexComponent(this.red);
const green = toHexComponent(this.green);
const blue = toHexComponent(this.blue);
return `#${red}${green}${blue}`;
}
}

View File

@ -0,0 +1,941 @@
import { isEqual, isEqualWith, includes, remove, PropertyName, isEmpty, intersection, difference, cloneDeepWith } from 'lodash';
import { isMoment, isDuration } from 'moment-timezone';
import { Guid } from '@microsoft/sp-core-library';
import { User } from "./User";
import { IUserListChanges } from "./IUserListChanges";
import { ValidationRule } from "./ValidationRules";
import { reverseComparer, Comparer, PropsOfType, inverseFilter } from './Utils';
const Version: unique symbol = Symbol("Version");
const Current: unique symbol = Symbol("Current");
const Previous: unique symbol = Symbol("Previous");
const Snapshot: unique symbol = Symbol("Snapshot");
const LiveUpdateTemp: unique symbol = Symbol("Live Update");
export const stateIsEqualCustomizer = (value: any, other: any, indexOrKey: PropertyName | undefined, parent: any, otherParent: any, stack: any): boolean | undefined => {
if (indexOrKey === Version) {
return true;
} else if (isMoment(value) && isMoment(other)) {
if (!value.isValid() && !other.isValid())
return true;
else
return value.isSame(other);
} else if (isDuration(value) && isDuration(other)) {
if (!value.isValid() && !other.isValid())
return true;
else
return value.asMilliseconds() === other.asMilliseconds();
} else if (value instanceof Map && other instanceof Map) {
return isEqual(value, other);
} else if (value instanceof Set && other instanceof Set) {
return isEqual(value, other);
} else if (Array.isArray(value) && Array.isArray(other) && indexOrKey) {
return isEqualWith(value, other, stateIsEqualCustomizer);
} else if (value instanceof User && other instanceof User) {
return User.equal(value, other);
} else if (value instanceof Guid && other instanceof Guid) {
return value.equals(other);
}
}
interface ISnapshot {
readonly hasSnapshot: boolean;
snapshot(): void;
revert(): void;
immortalize(): void;
}
interface ILiveUpdate {
readonly hasPrevious: boolean;
readonly inLiveUpdate: boolean;
beginLiveUpdate(): void;
endLiveUpdate(): void;
}
type ExtractEntityStateType<E> = E extends Entity<infer S, any> ? S : never;
type IEntityState<S, ID extends string | number = number> = S & {
id: ID;
deleted: boolean;
[Version]: number;
};
type States<S, ID extends string | number = number> = {
[Current]: IEntityState<S, ID>;
[Previous]?: IEntityState<S, ID>;
[Snapshot]?: IEntityState<S, ID>;
[LiveUpdateTemp]?: IEntityState<S, ID>;
};
export interface IEntity<ID extends string | number = number> extends ISnapshot, ILiveUpdate {
readonly id: ID;
readonly displayName: string;
readonly isNew: boolean;
readonly isDeleted: boolean;
readonly softDeleteSupported: boolean;
hasChanges(): boolean;
valid(): boolean;
delete(): void;
snapshotValue(property: string | number | symbol): any;
previousValue(property: string | number | symbol): any;
isSearchMatch(text: string, matchAllWords?: boolean): boolean;
}
export abstract class Entity<S, ID extends string | number = number> implements IEntity<ID> {
public static readonly DisplayNameAscComparer = <S>(a: Entity<S>, b: Entity<S>) => a.displayName.localeCompare(b.displayName);
public static readonly DisplayNameDescComparer = reverseComparer(Entity.DisplayNameAscComparer);
public static readonly NotDeletedFilter = <S>({ isDeleted }: Entity<S>): boolean => !isDeleted;
public static readonly NewAndDeletedFilter = <S>({ isNew, isDeleted }: Entity<S>): boolean => isNew && isDeleted;
public static readonly NewAndGhostableFilter = <S>({ isNew, allowGhosting }: Entity<S>): boolean => isNew && allowGhosting;
public static search<E extends Entity<S>, S>(entities: E[], text: string, matchAllWords?: boolean): E[];
public static search<E extends Entity<S>, S>(entities: readonly E[], text: string, matchAllWords?: boolean): readonly E[];
public static search<E extends Entity<S>, S>(entities: E[] | readonly E[], text: string, matchAllWords: boolean = true): E[] | readonly E[] {
if (text) {
const words = Entity._buildSearchWords(text);
return entities.filter(entity => entity._isSearchMatchCore(words, matchAllWords));
} else {
return entities;
}
}
private static _buildSearchWords(text: string): string[] {
return text.toLocaleLowerCase().split(' ');
}
private readonly _validateRules: ValidationRule<Entity<S, any>>[];
private _states: States<S, ID>;
private _activeState: keyof States<S, ID>;
private _peekPriorState: keyof States<S, ID>;
private _searchHelpers: string[];
private _boundedContextParticipants: Set<ISnapshot & ILiveUpdate>;
constructor(id?: ID) {
this._validateRules = this.validationRules() || [];
this._states = {
[Current]: {
id,
deleted: false,
[Version]: 0
} as IEntityState<S, ID>
};
this._activeState = Current;
this._peekPriorState = null;
this._searchHelpers = null;
this._boundedContextParticipants = new Set();
}
public get id(): ID {
return this._states[this._activeState].id;
}
private _key: Guid;
public get key(): string {
return (this.id || (this._key ||= Guid.newGuid())).toString();
}
public abstract get displayName(): string;
public get isNew(): boolean {
return !this.id;
}
public get isDeleted(): boolean {
return this._states[this._activeState].deleted;
}
public setId(id: ID) {
if (this.isNew) {
this._states[this._activeState].id = id;
}
}
public hasChanges(specificProperty?: string | number | symbol): boolean {
if (this.isNew && !this.allowGhosting) {
return true;
} else if (this.hasSnapshot) {
const current = specificProperty ? this.currentValue(specificProperty) : this._states[Current];
const snapshot = specificProperty ? this.snapshotValue(specificProperty) : this._states[Snapshot];
return !isEqualWith(current, snapshot, this.stateIsEqualCustomizer);
} else {
return false;
}
}
public valid(): boolean {
return this._validateRules.every(rule => rule.validate(this));
}
public delete() {
this._states[this._activeState].deleted = true;
}
public undelete() {
if (this.isDeleted) {
const state = this._states[this._activeState];
state.deleted = false;
if (!this.softDeleteSupported) {
if (this.hasPrevious && !this.previousValue<boolean>("isDeleted") &&
this.hasSnapshot && this.snapshotValue<boolean>("isDeleted")) {
state.id = undefined;
}
}
}
}
public stateVersion(): number {
return this._states[this._activeState][Version];
}
protected includeInBoundedContext(relationship: ISnapshot & ILiveUpdate) {
this._boundedContextParticipants.add(relationship);
}
public isInBoundedContext(relationship: ISnapshot & ILiveUpdate): boolean {
return this._boundedContextParticipants.has(relationship);
}
public get hasSnapshot(): boolean {
return !!this._states[Snapshot];
}
public get hasPrevious(): boolean {
return !!this._states[Previous];
}
public get inLiveUpdate(): boolean {
return !!this._states[LiveUpdateTemp];
}
public snapshot() {
if (!this.hasSnapshot) {
this._states[Snapshot] = this._states[Current];
this._states[Current] = this._copyState(this._states[Current]);
this._boundedContextParticipants.forEach(participant => participant.snapshot());
}
}
public revert() {
if (this.hasSnapshot) {
this._states[Current] = this._states[Snapshot];
this._states[Snapshot] = null;
this._boundedContextParticipants.forEach(participant => participant.revert());
}
}
public immortalize() {
if (this.hasSnapshot) {
if (this.hasChanges()) {
this._searchHelpers = null;
this._states[Current][Version]++;
} else
this._states[Current] = this._states[Snapshot];
this._states[Snapshot] = null;
this._boundedContextParticipants.forEach(participant => participant.immortalize());
}
if (this.hasPrevious) {
this._states[Previous] = null;
}
}
public isSearchMatch(text: string, matchAllWords: boolean = true): boolean {
return text ? this._isSearchMatchCore(Entity._buildSearchWords(text), matchAllWords) : true;
}
public get softDeleteSupported(): boolean {
return false;
}
public get allowGhosting(): boolean {
return false;
}
protected get state(): S {
return this._states[this._activeState];
}
public beginLiveUpdate(isNew: boolean = false) {
if (!this.inLiveUpdate) {
if (this.hasSnapshot) {
this._states[LiveUpdateTemp] = this._states[Snapshot];
this._states[Snapshot] = this._copyState(this._states[Snapshot]);
this._activeState = Snapshot;
} else {
this._states[LiveUpdateTemp] = this._states[Current];
this._states[Current] = this._copyState(this._states[Current]);
}
if (isNew) {
this._states[LiveUpdateTemp].id = undefined;
}
this._boundedContextParticipants.forEach(participant => participant.beginLiveUpdate());
}
}
public endLiveUpdate() {
if (this.inLiveUpdate) {
this._states[Previous] = this._states[Previous] || this._states[LiveUpdateTemp];
const isNew = !this._states[Previous].id;
const liveUpdateMadeChanges = !isNew && !isEqualWith(this._states[Previous], this.hasSnapshot ? this._states[Snapshot] : this._states[Current], this.stateIsEqualCustomizer);
this._activeState = Current;
if (isNew) {
this._states[Previous] = this._copyState(this._states[Current]);
} else if (this.hasSnapshot) {
if (liveUpdateMadeChanges) {
const copy = this._copyState(this._states[Snapshot]);
// eslint-disable-next-line guard-for-in
for (const prop in copy) {
const current_value = (this._states[Current] as any)[prop];
const prior_snapshot_value = (this._states[LiveUpdateTemp] as any)[prop];
const propValuesAreEqual = isEqualWith(current_value, prior_snapshot_value, this.stateIsEqualCustomizer);
if (propValuesAreEqual) {
(this._states[Current] as any)[prop] = (copy as any)[prop];
}
}
} else {
this._states[Snapshot] = this._states[Previous];
this._states[Previous] = null;
}
} else if (!liveUpdateMadeChanges) {
this._states[Current] = this._states[Previous];
this._states[Previous] = null;
}
this._states[LiveUpdateTemp] = null;
this._boundedContextParticipants.forEach(participant => participant.endLiveUpdate());
}
}
public currentValue<T = any>(property: string | number | symbol): T {
this.peekCurrent();
const value = (this as any)[property];
this.endPeek();
return value as T;
}
public previousValue<T = any>(property: string | number | symbol): T {
this.peekPrevious();
const value = (this as any)[property];
this.endPeek();
return value as T;
}
public snapshotValue<T = any>(property: string | number | symbol): T {
this.peekSnapshot();
const value = (this as any)[property];
this.endPeek();
return value as T;
}
public peekCurrent() {
this._peekPriorState = this._activeState;
this._activeState = Current;
}
public peekSnapshot() {
this._peekPriorState = this._activeState;
this._activeState = Snapshot;
}
public peekPrevious() {
this._peekPriorState = this._activeState;
this._activeState = Previous;
}
public endPeek() {
if (this._peekPriorState) {
this._activeState = this._peekPriorState;
this._peekPriorState = null;
}
}
protected usersDifference(propertyName: PropsOfType<S, User[]>): IUserListChanges {
if (this.hasSnapshot) {
const current = this._states[Current][propertyName] as unknown as User[];
const snapshot = this._states[Snapshot][propertyName] as unknown as User[];
return {
added: User.except(current, snapshot),
removed: User.except(snapshot, current)
};
} else {
return { added: [], removed: [] };
}
}
protected stateIsEqualCustomizer(value: any, other: any, indexOrKey: PropertyName | undefined, parent: any, otherParent: any, stack: any): boolean | undefined {
return stateIsEqualCustomizer(value, other, indexOrKey, parent, other, stack);
}
protected cloneStateCustomizer<T>(value: any, key: number | string | undefined | symbol, object: T | undefined, stack: any): any {
if (key === Version)
return value + 1;
else if (value instanceof Entity)
return value;
else if (isMoment(value) || isDuration(value))
return value.clone();
else if (typeof value?.clone === "function")
return value.clone();
}
private _copyState(state: IEntityState<S, ID>): IEntityState<S, ID> {
return cloneDeepWith(state, this.cloneStateCustomizer);
}
private _createSearchHelpers() {
this._searchHelpers = [
this.displayName,
...this.buildSearchHelperStrings()
].filter(Boolean).map(str => str.toLocaleLowerCase());
}
private _searchHelpersContain(text: string) {
if (!this._searchHelpers) {
this._createSearchHelpers();
}
return this._searchHelpers.some(helper => helper.includes(text));
}
private _isSearchMatchCore(words: string[], matchAllWords: boolean = true): boolean {
if (matchAllWords)
return words.every(word => this._searchHelpersContain(word));
else
return words.some(word => this._searchHelpersContain(word));
}
protected buildSearchHelperStrings(): string[] { return []; }
protected validationRules(): ValidationRule<Entity<S, any>>[] { return []; }
}
export enum EntityRelationshipSortOption {
OnImmortalize = 0,
OnAdd = 1,
OnAddAndInsert = 2
}
export interface IRelationshipSortingParameters<T> {
comparer: Comparer<T>;
option?: EntityRelationshipSortOption;
}
export interface IManyToOneRelationship<TParent> extends ISnapshot, ILiveUpdate {
get(): TParent;
getSnapshot(): TParent;
getPrevious(): TParent;
set(val: TParent): void;
}
interface IToManyRelationshipBase<TRelated> extends ISnapshot, ILiveUpdate {
get(): ReadonlyArray<TRelated>;
readonly hasItems: boolean;
hasChanges(): boolean;
add(entity: TRelated): void;
insert(entity: TRelated, index?: number): void;
remove(entity: TRelated): void;
removeAll(): void;
forEach(callbackfn: (value: TRelated, index: number, array: readonly TRelated[]) => void, thisArg?: any): void;
map<U>(callbackfn: (value: TRelated, index: number, array: readonly TRelated[]) => U): U[];
filter(callbackfn: (value: TRelated, index: number, array: readonly TRelated[]) => unknown): TRelated[];
find(callbackfn: (value: TRelated, index: number, array: readonly TRelated[]) => boolean): TRelated | undefined;
addNotificationHandler(handler: () => void): void;
removeNotificationHandler(handler: () => void): void;
snapshotValue(): readonly TRelated[];
previousValue(): readonly TRelated[];
sorting?: Readonly<IRelationshipSortingParameters<TRelated>>;
}
type ToManyRelationshipState<TRelated extends Entity<any, any>> = {
[Current]: TRelated[];
[Previous]?: TRelated[];
[Snapshot]?: TRelated[];
[LiveUpdateTemp]?: TRelated[];
};
export abstract class ToManyRelationshipBase<TEntity extends Entity<any, any>, TRelated extends Entity<any, any>> implements IToManyRelationshipBase<TRelated> {
private _states: ToManyRelationshipState<TRelated>;
private _activeState: keyof ToManyRelationshipState<TRelated>;
private _handlers: Set<() => void> = new Set();
protected constructor(
protected readonly _parent: TEntity,
public readonly sorting?: Readonly<IRelationshipSortingParameters<TRelated>>
) {
this._states = {
[Current]: []
};
this._activeState = Current;
}
public addNotificationHandler(handler: () => void) {
this._handlers.add(handler);
}
public removeNotificationHandler(handler: () => void) {
this._handlers.delete(handler);
}
protected get state(): TRelated[] {
return this._states[this._activeState];
}
public get(): ReadonlyArray<TRelated> {
return this.state;
}
public get hasItems(): boolean {
return !isEmpty(this.state);
}
public hasChanges(): boolean {
let hasChanges: boolean = false;
const currentItems = this._states[Current].filter(ToManyRelationshipBase.NotNewAndDeletedFilter);
if (this.hasSnapshot) {
const snapshotItems = this._states[Snapshot];
const currentNonGhostableItems = currentItems.filter(ToManyRelationshipBase.NotNewAndGhostableFilter);
hasChanges = !isEqual(currentNonGhostableItems, snapshotItems);
hasChanges ||= currentItems.some(bc => bc.hasChanges());
}
return hasChanges;
}
private static readonly NotNewAndDeletedFilter = inverseFilter(Entity.NewAndDeletedFilter);
private static readonly NotNewAndGhostableFilter = inverseFilter(Entity.NewAndGhostableFilter);
public add(entity: TRelated) {
if (this._insertCore(entity)) {
if (this._shouldSortOnAdd)
this._sort();
}
}
public insert(entity: TRelated, index?: number) {
if (this._insertCore(entity, index)) {
if (this._shouldSortOnInsert)
this._sort();
}
}
private _insertCore(entity: TRelated, index?: number): boolean {
if (entity && !includes(this.state, entity)) {
if (this._parent.isInBoundedContext(this)) {
if (entity.hasSnapshot) this.snapshot();
else if (this.hasSnapshot) entity.snapshot();
if (entity.inLiveUpdate) this.beginLiveUpdate();
else if (this.inLiveUpdate) entity.beginLiveUpdate();
}
if (index === 0)
this.state.unshift(entity);
else if (index < this.state.length)
this.state.splice(index, 0, entity);
else
this.state.push(entity);
this.addInRelated(entity);
this._notifyHandlers();
return true;
} else {
return false;
}
}
protected abstract addInRelated(entity: TRelated): void;
public remove(entity: TRelated): void {
if (!entity) return;
remove(this.state, item => item === entity)
.map(item => this.removeInRelated(item));
this._notifyHandlers();
}
public removeAll(): void {
this.state
.splice(0, this.state.length)
.map(item => this.removeInRelated(item));
this._notifyHandlers();
}
protected abstract removeInRelated(entity: TRelated): void;
public forEach(callbackfn: (value: TRelated, index: number, array: readonly TRelated[]) => void): void {
this.state.forEach(callbackfn);
}
public map<U>(callbackfn: (value: TRelated, index: number, array: readonly TRelated[]) => U): U[] {
return this.state.map(callbackfn);
}
public filter(callbackfn: (value: TRelated, index: number, array: readonly TRelated[]) => unknown): TRelated[] {
return this.state.filter(callbackfn);
}
public find(callbackfn: (value: TRelated, index: number, array: readonly TRelated[]) => value is TRelated): TRelated | undefined {
return this.state.find(callbackfn);
}
public get hasSnapshot(): boolean {
return !!this._states[Snapshot];
}
public get hasPrevious(): boolean {
return !!this._states[Previous];
}
public get inLiveUpdate(): boolean {
return !!this._states[LiveUpdateTemp];
}
public snapshot() {
if (!this.hasSnapshot) {
this._states[Snapshot] = this._states[Current].slice();
this.forEach(item => item.snapshot());
}
}
public revert() {
if (this.hasSnapshot) {
difference(this.snapshotValue(), this.get()).forEach(item => item.revert());
this._states[Current] = this._states[Snapshot];
this._states[Snapshot] = null;
this.forEach(item => item.revert());
}
}
public immortalize() {
if (this.hasSnapshot) {
if (this.hasChanges()) {
difference(this.snapshotValue(), this.get()).forEach(item => item.immortalize());
if (this._shouldSortOnImmortalize) this._sort();
} else {
this._states[Current] = this._states[Snapshot];
}
this._states[Snapshot] = null;
this.forEach(item => item.immortalize());
remove(this.state, Entity.NewAndDeletedFilter).map(item => this.removeInRelated(item));
remove(this.state, Entity.NewAndGhostableFilter).map(item => this.removeInRelated(item));
}
if (this.hasPrevious) {
this._states[Previous] = null;
}
}
public previousValue(): readonly TRelated[] {
return this._states[Previous];
}
public snapshotValue(): readonly TRelated[] {
return this._states[Snapshot];
}
public beginLiveUpdate() {
if (!this.inLiveUpdate) {
if (this.hasSnapshot) {
this._states[LiveUpdateTemp] = this._states[Snapshot];
this._states[Snapshot] = this._states[Snapshot].slice();
this._activeState = Snapshot;
} else {
this._states[LiveUpdateTemp] = this._states[Current];
this._states[Current] = this._states[Current].slice();
}
this.forEach(item => item.beginLiveUpdate());
this._parent.beginLiveUpdate();
}
}
public endLiveUpdate() {
if (this.inLiveUpdate) {
this._states[Previous] = this._states[Previous] || this._states[LiveUpdateTemp];
this._sort();
// TODO: need a better way to access this information from the parent entity
const isNew = this._parent.hasPrevious && isEqualWith((this._parent as any)._states[Current], (this._parent as any)._states[Previous], (this._parent as any).stateIsEqualCustomizer);
const liveUpdateMadeChanges = !isNew && !isEqual(this._states[Previous], this.hasSnapshot ? this._states[Snapshot] : this._states[Current]);
const noItemsHavePrevious = this.state.every(item => !item.hasPrevious);
if (isNew) {
this._states[Previous] = this._states[Current].slice();
} if (this.hasSnapshot) {
this._activeState = Current;
if (liveUpdateMadeChanges) {
// if exists in both current and prior snapshot but not this snapshot, remove it from current
const inBothCurrentAndPriorSnapshot = intersection(this._states[Current], this._states[LiveUpdateTemp]);
const notInSnapshot = difference(inBothCurrentAndPriorSnapshot, this._states[Snapshot]);
remove(this._states[Current], item => notInSnapshot.includes(item));
// if exists in snapshot but does not exist in either current or prior snapshot, add it to current
const inSnapshotButNotOthers = difference(this._states[Snapshot], this._states[Current], this._states[LiveUpdateTemp]);
this._states[Current].push(...inSnapshotButNotOthers);
} else if (noItemsHavePrevious) {
this._states[Snapshot] = this._states[Previous];
this._states[Previous] = null;
}
} else if (!liveUpdateMadeChanges && noItemsHavePrevious) {
this._states[Current] = this._states[Previous];
this._states[Previous] = null;
}
this._states[LiveUpdateTemp] = null;
difference(this.previousValue(), this.get()).forEach(item => item.endLiveUpdate());
this.forEach(item => item.endLiveUpdate());
this._parent.endLiveUpdate();
}
}
private get _shouldSortOnAdd(): boolean {
if (!!this.sorting) {
switch (this.sorting.option) {
case EntityRelationshipSortOption.OnAdd:
case EntityRelationshipSortOption.OnAddAndInsert:
return true;
case EntityRelationshipSortOption.OnImmortalize:
default:
return !this.hasSnapshot;
}
} else {
return false;
}
}
private get _shouldSortOnInsert(): boolean {
if (!!this.sorting) {
switch (this.sorting.option) {
case EntityRelationshipSortOption.OnAddAndInsert:
return true;
case EntityRelationshipSortOption.OnAdd:
case EntityRelationshipSortOption.OnImmortalize:
default:
return !this.hasSnapshot;
}
} else {
return false;
}
}
private get _shouldSortOnImmortalize(): boolean {
if (!!this.sorting) {
switch (this.sorting.option) {
case EntityRelationshipSortOption.OnAdd:
case EntityRelationshipSortOption.OnAddAndInsert:
return false;
case EntityRelationshipSortOption.OnImmortalize:
default:
return true;
}
} else {
return false;
}
}
private _sort() {
if (!!this.sorting) {
this.state.sort(this.sorting.comparer);
}
}
private _notifyHandlers() {
this._handlers.forEach(handler => handler());
}
}
export interface IOneToManyRelationship<TChild> extends IToManyRelationshipBase<TChild> {
}
export class OneToManyRelationship<TParent extends Entity<any, any>, TChild extends Entity<any, any>> extends ToManyRelationshipBase<TParent, TChild> {
public static create<TParent extends Entity<any, any>, TChild extends Entity<any, any>>(
parent: TParent,
property: PropsOfType<TChild, IManyToOneRelationship<TParent>>,
sorting?: IRelationshipSortingParameters<TChild>
): IOneToManyRelationship<TChild> {
return new OneToManyRelationship<TParent, TChild>(parent, property, sorting);
}
private constructor(
parent: TParent,
private readonly _property: PropsOfType<TChild, IManyToOneRelationship<TParent>>,
sorting?: Readonly<IRelationshipSortingParameters<TChild>>
) {
super(parent, sorting);
}
protected addInRelated(entity: TChild): void {
this._parentRelationship(entity).set(this._parent);
}
protected removeInRelated(entity: TChild): void {
this._parentRelationship(entity).set(null);
}
// @internal
public readonly _parentRelationship = (entity: TChild): IManyToOneRelationship<TParent> => {
return (entity as any)[this._property];
}
}
export interface IManyToManyRelationship<TRelated> extends IToManyRelationshipBase<TRelated> {
set(entities: TRelated[]): void;
}
export class ManyToManyRelationship<TEntity extends Entity<any, any>, TRelated extends Entity<any, any>> extends ToManyRelationshipBase<TEntity, TRelated> {
public static create<TEntity extends Entity<any, any>, TRelated extends Entity<any, any>>(
entity: TEntity,
relatedCollectionProperty: PropsOfType<TRelated, IManyToManyRelationship<TEntity>>,
sorting?: IRelationshipSortingParameters<TRelated>
): IManyToManyRelationship<TRelated> {
return new ManyToManyRelationship<TEntity, TRelated>(entity, relatedCollectionProperty, sorting);
}
constructor(
parent: TEntity,
private readonly _property: PropsOfType<TRelated, IManyToManyRelationship<TEntity>>,
sorting?: IRelationshipSortingParameters<TRelated>
) {
super(parent, sorting);
}
public set(entities: TRelated[]): void {
this.removeAll();
entities.forEach(entity => this.add(entity));
}
protected addInRelated(entity: TRelated): void {
this._parentRelationship(entity).add(this._parent);
}
protected removeInRelated(entity: TRelated): void {
this._parentRelationship(entity).remove(this._parent);
}
private _parentRelationship = (entity: TRelated): IManyToManyRelationship<TEntity> => {
return (entity as any)[this._property];
}
}
export class ManyToOneRelationship<TChild extends Entity<any, any>, TParent extends Entity<any, any>> implements IManyToOneRelationship<TParent> {
public static create<TChild extends Entity<any, any>, TParent extends Entity<any, any>>(
entity: TChild,
childCollectionProperty: PropsOfType<TParent, IOneToManyRelationship<TChild>>,
stateProperty: keyof ExtractEntityStateType<TChild>
): IManyToOneRelationship<TParent> {
return new ManyToOneRelationship<TChild, TParent>(entity, childCollectionProperty, stateProperty);
}
private constructor(
private readonly _entity: TChild,
private readonly _childCollectionProperty: PropsOfType<TParent, IOneToManyRelationship<TChild>>,
private readonly _stateProperty: keyof ExtractEntityStateType<TChild>
) { }
public get(): TParent {
// eslint-disable-next-line dot-notation
return this._entity['state'][this._stateProperty] as TParent;
}
public getSnapshot(): TParent {
return this._entity.hasSnapshot ? this._entity['_states'][Snapshot][this._stateProperty] as TParent : undefined;
}
public getPrevious(): TParent {
return this._entity.hasPrevious ? this._entity['_states'][Previous][this._stateProperty] as TParent : undefined;
}
public set(newParent: TParent) {
const current = this.get();
if (current !== newParent) {
// eslint-disable-next-line dot-notation
this._entity['state'][this._stateProperty] = newParent;
if (!!current) {
this._childRelationship(current).remove(this._entity);
}
if (newParent) {
this._childRelationship(newParent).add(this._entity);
}
}
}
public get hasSnapshot(): boolean {
return this.get()?.hasSnapshot;
}
public get hasPrevious(): boolean {
return this.get()?.hasPrevious;
}
public get inLiveUpdate(): boolean {
return this.get().inLiveUpdate;
}
public snapshot() {
this.get()?.snapshot();
}
public revert() {
if (this.hasSnapshot) {
const snapshot = this.getSnapshot();
const current = this.get();
if (snapshot !== current) snapshot?.revert();
current?.revert();
}
}
public immortalize() {
if (this.hasSnapshot) {
const snapshot = this.getSnapshot();
const current = this.get();
if (snapshot !== current) snapshot?.immortalize();
current?.immortalize();
}
}
public beginLiveUpdate() {
this.get()?.beginLiveUpdate();
}
public endLiveUpdate() {
const previous = this.getPrevious();
const current = this.get();
if (previous !== current) previous?.endLiveUpdate();
current?.endLiveUpdate();
}
private _childRelationship = (entity: TParent): IOneToManyRelationship<TChild> => {
return (entity as any)[this._childCollectionProperty];
}
}

View File

@ -0,0 +1,53 @@
import { HttpRequestError } from "@pnp/odata";
import { ValidationError } from "./ValidationError";
export class ErrorHandler {
private _error: any;
public get hasError() { return !!this._error; }
public readonly catch = (error: any) => {
if (!this.hasError) {
this._error = error;
}
}
public readonly throwIfError = () => {
if (this.hasError) {
ErrorHandler.throw(this._error);
}
}
public readonly reportIfError = () => {
if (this.hasError) {
console.error(this._error);
}
}
public static readonly throw = (error: any) => {
console.error(error);
throw error;
}
public get is_400_BAD_REQUEST() { return ErrorHandler.is_400_BAD_REQUEST(this._error); }
public static readonly is_400_BAD_REQUEST = (error: any) => ErrorHandler.isErrorCode(error, 400);
public get is_404_NOT_FOUND() { return ErrorHandler.is_404_NOT_FOUND(this._error); }
public static readonly is_404_NOT_FOUND = (error: any) => ErrorHandler.isErrorCode(error, 404);
public get is_412_PRECONDITION_FAILED() { return ErrorHandler.is_412_PRECONDITION_FAILED(this._error); }
public static readonly is_412_PRECONDITION_FAILED = (error: any) => ErrorHandler.isErrorCode(error, 412);
public get is_423_LOCKED() { return ErrorHandler.is_423_LOCKED(this._error); }
public static readonly is_423_LOCKED = (error: any) => ErrorHandler.isErrorCode(error, 423);
public get is_SPO_ValidationError() { return ErrorHandler.is_SPO_ValidationError(this._error); }
public static readonly is_SPO_ValidationError = (error: any): error is ValidationError =>
error && (error instanceof ValidationError)
private static readonly isErrorCode = (error: any, code: number) =>
error && (error as HttpRequestError).isHttpRequestError && (error as HttpRequestError).status === code
public static readonly message = async (error: any): Promise<string> =>
(error && (error as HttpRequestError).isHttpRequestError && (await (error as HttpRequestError).response.clone().json())['odata.error']?.message?.value) || error?.message || error
}

View File

@ -0,0 +1,7 @@
export class GroupByOption<T> {
constructor(
public readonly key: string,
public readonly title: string,
public readonly groupByKey: (item: T) => any
) { }
}

View File

@ -0,0 +1,3 @@
export interface IComponent {
componentShouldRender(): void;
}

View File

@ -0,0 +1,3 @@
export interface IKey {
valueOf(): number;
}

View File

@ -0,0 +1,6 @@
import { User } from "./User";
export interface IUserListChanges {
added: User[];
removed: User[];
}

View File

@ -0,0 +1,50 @@
import { includes, remove, noop } from 'lodash';
import { Entity } from "./Entity";
export abstract class Loader<E extends Entity<any>> {
protected readonly _trackedEntities: E[] = [];
protected readonly _entities: E[] = [];
protected readonly _entitiesById: Map<number, E> = new Map<number, E>();
public abstract entitiesById(): Promise<ReadonlyMap<number, E>>;
public get entitiesWithChanges(): readonly E[] {
return [
...this._trackedEntities,
...this._entities.filter(e => e.hasChanges())
];
}
public track(entity: E): void {
if (!includes(this._trackedEntities, entity) && !includes(this._entities, entity)) {
this._trackedEntities.push(entity);
}
}
protected untrack(entity: E): void {
remove(this._trackedEntities, entry => entry === entity);
}
public async persist(singleEntity?: E): Promise<void> {
const previous = this._previousPersistPromise;
await (this._previousPersistPromise = (async () => {
await previous.catch(noop);
await this.persistCore(singleEntity);
})());
}
protected _previousPersistPromise: Promise<any> = Promise.resolve();
protected abstract persistCore(singleEntity?: E): Promise<void>;
protected readonly refreshEntityCollections = (): void => {
remove(this._entities, entity => !entity.softDeleteSupported && entity.isDeleted);
const committed = remove(this._trackedEntities, e => !e.isNew);
this._entities.push(...committed);
this._entitiesById.clear();
this._entities.forEach(entity => {
this._entitiesById.set(entity.id, entity);
});
}
}

View File

@ -0,0 +1,36 @@
export interface IButtonStrings {
Text: string;
AriaLabel?: string;
Description?: string;
Tooltip?: string;
}
export interface IFieldStrings {
Label: string;
AriaLabel?: string;
Tooltip?: string;
}
export interface ITextFieldStrings extends IFieldStrings {
Placeholder?: string;
}
export interface IToggleFieldStrings extends IFieldStrings {
OnText: string;
OffText: string;
}
export interface IDialogStrings {
HeadingText: string;
MessageText: string;
AcceptButton?: IButtonStrings;
RejectButton?: IButtonStrings;
}
export interface IWizardStrings {
StartButton: IButtonStrings;
BackButton: IButtonStrings;
NextButton: IButtonStrings;
FinishButton: IButtonStrings;
CloseButtonAriaLabel: string;
}

View File

@ -0,0 +1,9 @@
import { Moment, unitOfTime } from "moment-timezone";
export class MomentRange {
public start: Moment;
public end: Moment;
public static overlaps = (range1: MomentRange, range2: MomentRange, units: unitOfTime.StartOf = 'day'): boolean =>
!range1.start.isAfter(range2.end) && !range1.end.isBefore(range2.start, units)
}

View File

@ -0,0 +1,9 @@
import React, { ComponentType } from "react";
import { Params, useParams } from "react-router-dom";
type TParamsProps = {
params: Partial<Params>;
};
export const withRouterParams = <P extends TParamsProps>(WrappedComponent: ComponentType<P>) =>
(props: Omit<P, keyof TParamsProps>) => <WrappedComponent {...(props as P)} params={useParams()} />

View File

@ -0,0 +1,8 @@
export class SortOption<T> {
constructor(
public readonly key: string,
public readonly title: string,
public readonly sortAscFn: (a: T, b: T) => number,
public readonly sortDescFn: (a: T, b: T) => number
) { }
}

View File

@ -0,0 +1,96 @@
import { first } from 'lodash';
import { Recipient, User as IUserType } from "@microsoft/microsoft-graph-types";
import { SPUser } from "@microsoft/sp-page-context";
import { IPrincipalInfo } from "@pnp/sp";
import { ISiteUserInfo } from "@pnp/sp/site-users/types";
export class User {
public static TitleAscComparer = (a: User, b: User) => a.title?.localeCompare(b.title);
public static equal(user1: User, user2: User): boolean {
if (user1 && user2)
return (user1.id > 0 && user2.id > 0 && user1.id === user2.id)
|| (user1.login && user2.login && user1.login === user2.login)
|| (user1.email && user2.email && user1.email === user2.email);
else
return false;
}
public static except(users1: User[], users2: User[]): User[] {
return users1.filter(om => !users2.some(m => User.equal(om, m)));
}
public static fromPrincipalInfo(info: IPrincipalInfo): User {
const { PrincipalId, DisplayName, Email, LoginName } = info;
return new User(PrincipalId, DisplayName, Email, LoginName);
}
public static fromSiteUserInfo(result: ISiteUserInfo): User {
const { Id, Title, Email, LoginName } = result;
return new User(Id, Title, Email, LoginName);
}
public static fromSPUser(spuser: SPUser): User {
const { displayName, email, loginName } = spuser;
return new User(0, displayName, email, loginName);
}
public static fromGraphUser(user: IUserType): User {
const { displayName, mail, userPrincipalName } = user;
return new User(0, displayName, mail, userPrincipalName);
}
public static fromRecipient(recipient: Recipient): User {
const { emailAddress: { name, address } } = recipient || { emailAddress: {} };
return new User(0, name, address, '');
}
private _id: number;
public get id(): number { return this._id; }
private _login: string;
public get login(): string { return this._login; }
private _picture: string;
public get picture(): string { return this._picture; }
constructor(
id: number,
public readonly title: string,
public readonly email: string,
login: string,
picture?: string) {
this._id = id;
this._login = login;
this._picture = picture || `/_layouts/15/userphoto.aspx?size=S&username=${email}`;
}
public updateId(id: number) {
this._id = id;
}
public updateLogin(login: string) {
this._login = login;
}
public updatePicture(url: string) {
this._picture = url;
}
public alias(): string {
let alias = (this.login || this.email).toLocaleLowerCase();
const atIndex = alias.indexOf('@');
alias = atIndex > 0 ? alias.slice(0, atIndex) : alias;
const pipeIndex = alias.lastIndexOf('|');
alias = pipeIndex > 0 ? alias.slice(pipeIndex + 1) : alias;
return alias;
}
public titleWithoutOrganisation(): string {
return first((this.title || '').split('('))?.trim();
}
}

View File

@ -0,0 +1,310 @@
import { minBy, maxBy, throttle } from 'lodash';
import moment, { Moment, Duration, MomentZone } from "moment-timezone";
import { sp, extractWebUrl } from "@pnp/sp";
import "@pnp/sp/folders";
import "@pnp/sp/webs";
import { IWeb, Web } from "@pnp/sp/webs/types";
import { BaseSyntheticEvent, ChangeEvent } from "react";
import { ISelectableOption, format } from "@fluentui/react";
import sanitizeHTML from 'sanitize-html';
import { Humanize as strings } from "CommonStrings";
export type ArrayType<A> = A extends Array<infer T> ? T : never;
export type UnionToIntersectionType<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
export type PropsOfType<T, TProp> = keyof Pick<T, { [Key in keyof T]: T[Key] extends TProp ? Key : never }[keyof T]>;
export type PartlyPartial<T, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K>;
export const perf = async <T = void>(label: string, action: () => Promise<T>): Promise<T> => {
console.time(label);
const result = await action();
console.timeEnd(label);
return result;
};
export const sleep = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};
export const now = (zoneName?: string): Moment => {
const n = moment();
const defaultZone = (moment as any).defaultZone as MomentZone;
const zone = zoneName || (defaultZone && defaultZone.name);
if (zone) n.tz(zone);
return n;
};
export const throttleOnSearchChange = (fn: (search: string) => void) => throttle(
(event?: ChangeEvent, search: string = '') => fn(search),
500,
{ leading: false, trailing: true }
);
export const parseIntOrDefault = (val: string, _default: number = 0.0, radix: number = 10): number => {
const num = parseInt(val, radix);
return isNaN(num) ? _default : num;
};
export const parseFloatOrDefault = (val: string, _default: number = 0.0): number => {
const num = parseFloat(val);
return isNaN(num) ? _default : num;
};
export const nameofFactory = <T extends {}>() => (name: keyof T) => name;
export const stringToEnum = <T extends string>(o: Array<T>): { [K in T]: K } => {
return o.reduce((res, key) => {
res[key] = key;
return res;
}, Object.create(null));
};
export const mapToArray = <K, V>(map: ReadonlyMap<K, V>): V[] => {
return Array.from(map.values());
};
export const mapGetOrAdd = <K, V>(map: Map<K, V>, key: K, create: () => V): V => {
if (map.has(key)) {
return map.get(key);
} else {
const value = create();
map.set(key, value);
return value;
}
};
export const arrayToMap = <K, V>(items: readonly V[], mapFn: (val: V) => K): Map<K, V> => {
return new Map<K, V>(items.map(item => [mapFn(item), item] as [K, V]));
};
export const distinct = <K, V>(items: readonly V[], keyFn?: (val: V) => K): V[] => {
if (keyFn) {
const map = new Map<K, V>();
items.forEach(item => mapGetOrAdd(map, keyFn(item), () => item));
return mapToArray(map);
} else {
return [...new Set(items)];
}
};
export const groupBy = <K, V>(items: readonly V[], mapFn: (val: V) => K): Map<K, V[]> => {
const groups = new Map<K, V[]>();
items.forEach(item => {
mapGetOrAdd(groups, mapFn(item), () => [])
.push(item);
});
return groups;
};
export type Filter<T> = (item: T) => boolean;
export const aggregateFilter = <T>(...filters: Filter<T>[]): Filter<T> =>
(item: T) => filters.reduce((result, filter) => result && filter(item), true);
export function multifilter<T>(items: T[], ...filters: Filter<T>[]): T[];
export function multifilter<T>(items: readonly T[], ...filters: Filter<T>[]): readonly T[];
export function multifilter<T>(items: (T[] | readonly T[]), ...filters: Filter<T>[]) {
return items.filter(aggregateFilter(...filters));
}
export const inverseFilter = <T>(filter: Filter<T>): Filter<T> =>
(item: T) => !filter(item);
export type Comparer<T> = (a: T, b: T) => number;
export const aggregateComparer = <T>(...comparers: Comparer<T>[]): Comparer<T> =>
(a: T, b: T) => comparers.reduce((result, compare) => result || compare(a, b), 0);
export const multisort = <T>(items: T[], ...comparers: Comparer<T>[]): T[] =>
items.sort(aggregateComparer(...comparers));
export const reverseComparer = <T>(comparer: Comparer<T>): Comparer<T> =>
(a: T, b: T) => -comparer(a, b);
export const dropdownTextAscComparer = (opt_a: ISelectableOption, opt_b: ISelectableOption): number => {
if (opt_a.text === opt_b.text)
return 0;
else
return opt_a.text > opt_b.text ? 1 : -1;
};
export const dateAscComparer: Comparer<Date> = (date_a, date_b) => (date_a?.valueOf() || 0) - (date_b?.valueOf() || 0);
export const momentAscComparer: Comparer<Moment> = (date_a, date_b) => date_a.diff(date_b);
export const durationAscComparer: Comparer<Duration> = (duration_a, duration_b) => ((duration_a && duration_a.isValid() && duration_a.asMilliseconds()) || 0) - ((duration_b && duration_b.isValid() && duration_b.asMilliseconds()) || 0);
export const distinctMoments = (dates: readonly Moment[], granularity: moment.unitOfTime.StartOf = 'day'): Moment[] => {
return dates.filter((d1, idx1) => dates.every((d2, idx2) => idx1 >= idx2 || !d1.isSame(d2, granularity)));
};
export const todayOrAfter = (date: Moment) => {
return moment.max(now(date.tz()).startOf('day'), date);
};
export const timeAsDuration = (date: Moment): Duration => {
return moment.duration(date.diff(moment(date).startOf('day')));
};
export const minDuration = (...durations: Duration[]) => minBy(durations, d => d.asMinutes());
export const maxDuration = (...durations: Duration[]) => maxBy(durations, d => d.asMinutes());
export const countAsString = (val: number, singularUnit: string, pluralUnit: string) => {
return val === 0 ? format(strings.ZeroCount, pluralUnit) : [val, val > 1 ? pluralUnit : singularUnit].join(' ');
};
export const humanizeDuration = (duration: Duration) => {
const totalMinutes = duration.asMinutes();
const totalHours = duration.asHours();
if (totalMinutes % 60 > 0 && totalMinutes > 60) {
return [
countAsString(Math.floor(totalHours), strings.HourShort, strings.HoursShort),
countAsString(duration.minutes(), strings.MinuteShort, strings.MinutesShort)
].join(' ').trim();
} else if (totalMinutes < 60) {
return countAsString(totalMinutes, strings.MinuteShort, strings.MinutesShort).trim();
} else if (duration.asHours() > 0) {
return countAsString(totalHours, strings.HourShort, strings.HoursShort).trim();
} else {
return '';
}
};
export const humanizeList = (items: readonly string[], separator: string = strings.ListSeparator, conjunction: string = strings.ListConjunction) => {
if (items.length <= 1) {
return items[0] || '';
}
else if (items.length === 2) {
return `${items[0]} ${conjunction} ${items[1]}`;
}
else {
return `${items.slice(0, -1).join(separator + ' ')}${separator} ${conjunction} ${items.slice(-1)}`;
}
};
export const humanizeFixedList = <T>(items: readonly T[], domain: readonly T[], toString: (item: T) => string, sort: boolean = true, allString: string = strings.ListAllItems, exceptString: string = strings.ListExcept, conjunction: string = strings.ListConjunction, separator: string = strings.ListSeparator) => {
const diff = new Set<T>(domain);
items.forEach(item => diff.delete(item));
if (diff.size === 0) {
return allString;
}
else if (items.length <= 3) {
const itemStrings = items.map(toString);
if (sort) itemStrings.sort();
return humanizeList(itemStrings, separator, conjunction);
}
else if (diff.size <= 2) {
const itemStrings = Array.from(diff).map(toString);
if (sort) itemStrings.sort();
return `${allString} ${exceptString} ${humanizeList(itemStrings, separator, conjunction)}`;
}
else {
const itemStrings = items.map(toString);
if (sort) itemStrings.sort();
return humanizeList(itemStrings, separator, conjunction);
}
};
export const buildCSVString = <T>(headings: string[], items: T[], valuesForItem: (items: T) => string[]): string => {
const buildCell = (value: string) => (value || '').replace(/"/g, '""');
const buildRow = (values: string[]) => `"${values.map(buildCell).join('","')}"`;
const headerRow = buildRow(headings);
const itemRows = items.map(valuesForItem).map(buildRow);
const csv = [headerRow, ...itemRows].join('\n');
return csv;
};
export const buildCSVBlob = <T>(headings: string[], items: T[], valuesForItem: (items: T) => string[]): Blob => {
const csv = buildCSVString(headings, items, valuesForItem);
return new Blob([csv], { type: "text/plain;charset=utf-8" });
};
export const isExecutingInWorkbench = () => window.location.pathname.includes('/_layouts/15/workbench.aspx');
export const isExecutingInTeamsTab = () => window.location.pathname.includes('/_layouts/15/teamshostedapp.aspx');
export const scrollParent = (element: Element): Element => {
if (isExecutingInWorkbench()) return document.getElementById('workbenchPageContent').children[0];
const overflowRegex = /(auto|scroll)/;
try {
let style = getComputedStyle(element);
const excludeStaticParent = style.position === "absolute";
if (style.position !== "fixed") {
for (let parent = element; (parent = parent.parentElement);) {
style = getComputedStyle(parent);
if (excludeStaticParent && style.position === "static") {
continue;
}
if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) {
return parent;
}
}
}
}
catch (e) {
// swallow any errors
}
return document.scrollingElement || document.documentElement;
};
export const publicMembersOnlyReplacer = (key: string, val: any) => key[0] === '_' ? undefined : val;
export const stopPropagation = (handler: (e?: BaseSyntheticEvent) => void) => (e: BaseSyntheticEvent) => { handler(e); e.stopPropagation(); };
export const currentPageServerRelativeUrl = async (): Promise<string> => {
const pathname = window.location.pathname;
if (pathname.indexOf(".aspx") > 0) {
return pathname;
} else {
const href = window.location.href;
const rootFolder = await sp.web.rootFolder.get();
return new URL(rootFolder.ServerRelativeUrl + rootFolder.WelcomePage, href).pathname;
}
};
export const sanitizeSharePointFolderName = (name: string): string =>
name.replace(/[~'"#%&*:<>?/\\{|}.]/g, '-').trim(); // folder name cannot have certain characters
export const sanitizeSharePointGroupName = (name: string): string =>
name.replace(/[\\/[\]|<>+=:;,?*'"@]/g, '-').trim(); // security group name cannot have certain characters
export const siteCollectionTermGroupName = (siteUrl: string): string =>
"Site Collection - " + siteUrl.replace(/^https?:\/\//, '').replace(/\//g, "-");
export const sanitizeHTMLWithDefaults = (value: string) => {
return sanitizeHTML(value, {
allowedTags: ['div', 'span', 'strong', 'b', 'p', 'a', 'title', 'h1', 'h2', 'h3', 'h4', 'h5', 'i', 'u',
'strike', 'ol', 'ul', 'li', 'font', 'br', 'hr', 'link',
'table', 'th', 'tr', 'td'],
allowedAttributes: {
a: ['href', 'target', 'data-interception']
},
allowedStyles: {
'*': {
// Match HEX and RGB
'color': [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/],
'text-align': [/^left$/, /^right$/, /^center$/],
'font-size': [/^\d+(?:px|em|rem|%)$/], // Match any number with px, em, rem, or %
'height': [/^0|\d+(?:px|em|%)$/], // Match '0' or any number with px, em, or %
'max-height': [/^0|\d+(?:px|em|%)$/],
'width': [/^0|\d+(?:px|em|%)$/],
'max-width': [/^0|\d+(?:px|em|%)$/]
},
'p': {
'font-size': [/^\d+(?:px|em|rem|%)$/]
},
'table': {
'table-layout': [/^fixed$/]
}
}
});
};
export const cloneWeb = (web?: IWeb) =>
web ? Web(extractWebUrl(web.toUrl())) : sp.web;

View File

@ -0,0 +1,9 @@
export class ValidationError extends Error {
constructor(
public readonly fieldName: string,
public readonly fieldErrorMessage: string,
) {
super(`${fieldName}: ${fieldErrorMessage}`);
Object.setPrototypeOf(this, ValidationError.prototype); // workaround to fix instanceof behavior for Error-derived classes https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
}
}

View File

@ -0,0 +1,148 @@
import { isFunction } from "lodash";
import { Text } from "@microsoft/sp-core-library";
import { Entity } from './Entity';
import * as cstrings from "CommonStrings";
type ValOrFunc<E, T> = T | ((entity: E) => T);
export class ValidationRule<E extends Entity<any, any>> {
constructor(
public readonly validate: (enitity: E) => boolean,
public readonly failMessage: ValOrFunc<E, string>
) { }
protected static eval<E extends Entity<any, any>, T>(entity: E, input: T | ((entity: E) => T)): T {
return isFunction(input) ? input(entity) : input;
}
}
export class RequiredValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => string | object,
failMessage: string = cstrings.Validation.Required
) {
super((e: E) => RequiredValidationRule.hasValue(field(e)), failMessage);
}
public static hasValue(val: any): boolean {
if (typeof val === "string") {
return !RequiredValidationRule._isBlank(val);
} else if (Array.isArray(val)) {
return val.length > 0;
} else if (typeof val?.isValid === "function") {
return val.isValid();
} else {
return !!val;
}
}
private static _isBlank(val: string): boolean {
return (!val || /^\s*$/.test(val));
}
}
export class MinValueValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => number | undefined,
minValue: ValOrFunc<E, number>,
failMessage: string = cstrings.Validation.MinimumValue
) {
super((e: E) => this._valueOrGreater(field(e), ValidationRule.eval(e, minValue)), e => Text.format(failMessage, ValidationRule.eval(e, minValue)));
}
private _valueOrGreater(val: number | undefined, minValue: number): boolean {
return (!val && val !== 0) || val >= minValue;
}
}
export class MaxValueValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => number | undefined,
maxValue: ValOrFunc<E, number>,
failMessage: string = cstrings.Validation.MaximumValue
) {
super(e => this._valueOrLess(field(e), ValidationRule.eval(e, maxValue)), e => Text.format(failMessage, ValidationRule.eval(e, maxValue)));
}
private _valueOrLess(val: number | undefined, maxValue: number): boolean {
return (!val && val !== 0) || val <= maxValue;
}
}
export class RangeValueValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => number | undefined,
minValue: ValOrFunc<E, number>,
maxValue: ValOrFunc<E, number>,
failMessage: string = cstrings.Validation.RangeValue
) {
super(e => this._isBetween(field(e), ValidationRule.eval(e, minValue), ValidationRule.eval(e, maxValue)), e => Text.format(failMessage, ValidationRule.eval(e, minValue), ValidationRule.eval(e, maxValue)));
}
private _isBetween(val: number | undefined, minValue: number, maxValue: number): boolean {
return (!val && val !== 0) || (val >= minValue && val <= maxValue);
}
}
export class MaxLengthValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => string,
maxLength: ValOrFunc<E, number>,
failMessage: string = cstrings.Validation.MaximumLength
) {
super((e: E) => field(e)?.length <= ValidationRule.eval(e, maxLength), e => Text.format(failMessage, ValidationRule.eval(e, maxLength)));
}
}
export class MaxItemsValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => ReadonlyArray<any>,
maxItems: ValOrFunc<E, number>,
failMessage: string = cstrings.Validation.MaximumItems
) {
super((e: E) => field(e)?.length <= ValidationRule.eval(e, maxItems), e => Text.format(failMessage, ValidationRule.eval(e, maxItems)));
}
}
export class UrlValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => string,
failMessage: string = cstrings.Validation.Url
) {
super((e: E) => this._isUrl(field(e)), failMessage);
}
private _isUrl(val: any): boolean {
const regexp = /^(((https|http|ftp):\/\/)(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?)?$/i;
return !val || regexp.test(val);
}
}
export class EmailValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => string,
failMessage: string = cstrings.Validation.Email
) {
super((e: E) => this._isEmailAddress(field(e)), failMessage);
}
private _isEmailAddress(val: any): boolean {
const regexp = /^[a-z0-9_\-.+]+@[a-z0-9_-]+(\.[a-z0-9_-]+)*$/i;
return !val || regexp.test(val);
}
}
export class PhoneValidationRule<E extends Entity<any, any>> extends ValidationRule<E> {
constructor(
field: (entity: E) => string,
failMessage: string = cstrings.Validation.Phone
) {
super((e: E) => this._isPhoneNumber(field(e)), failMessage);
}
private _isPhoneNumber(val: any): boolean {
const regexp = /^[0-9]{10}$/i;
return !val || regexp.test(val);
}
}

View File

@ -0,0 +1,99 @@
import sanitize from 'sanitize-html';
import React, { Component, ReactElement } from "react";
import { css, Panel, PanelType, DialogFooter, DefaultButton, Link, SpinnerSize } from "@fluentui/react";
import { IComponent } from "../IComponent";
import { IAsyncData } from "../AsyncData";
import { AsyncOverlay } from "./AsyncOverlay";
import * as strings from "CommonStrings";
import styles from "./styles/AsyncLoadComponent.module.scss";
interface IProps<T> {
dataAsync: IAsyncData<T>;
children: (data: T) => ReactElement;
className?: string;
hideSpinners?: boolean;
saveLabel?: string;
spinnerSize?: SpinnerSize;
}
interface IState {
showErrorDetailsPanel: boolean;
}
export class AsyncDataComponent<T> extends Component<IProps<T>, IState> implements IComponent {
constructor(props: IProps<T>) {
super(props);
this.state = {
showErrorDetailsPanel: false
};
}
public componentDidMount() {
const { dataAsync } = this.props;
if (dataAsync) dataAsync.registerComponentForUpdates(this);
}
public componentWillUnmount() {
const { dataAsync } = this.props;
if (dataAsync) dataAsync.unregisterComponentForUpdates(this);
}
public componentDidUpdate(prevProps: IProps<T>) {
const { dataAsync: nextAsyncData } = this.props;
const { dataAsync: prevAsyncData } = prevProps;
if (nextAsyncData !== prevAsyncData) {
if (prevAsyncData) prevAsyncData.unregisterComponentForUpdates(this);
nextAsyncData.registerComponentForUpdates(this);
}
}
public readonly componentShouldRender = () =>
this.forceUpdate()
private readonly _showErrorDetails = () =>
this.setState({ showErrorDetailsPanel: true })
private readonly _dismissErrorDetails = () =>
this.setState({ showErrorDetailsPanel: false })
public render() {
const { saveLabel, dataAsync, hideSpinners, className, children, spinnerSize } = this.props;
const { showErrorDetailsPanel } = this.state;
if (dataAsync) {
const { loaded, error, data, saving, done } = dataAsync;
const spinnersEnabled = !hideSpinners && !error;
const style = css(className, styles.asyncLoadComponent, { [styles.spinnersEnabled]: spinnersEnabled && !loaded });
return (
<div className={style}>
{error &&
<p className={styles.errorMessage}>
{strings.GenericError}&nbsp;&nbsp;
<Link className={styles.detailsLink} onClick={this._showErrorDetails}>Show details</Link>
</p>
}
<Panel headerText="Error Details" isOpen={showErrorDetailsPanel} onDismiss={this._dismissErrorDetails} type={PanelType.medium}>
{error &&
(error.stack
? <p dangerouslySetInnerHTML={{ __html: sanitize(error.stack) }} />
: <p>{error.toString()}</p>
)
}
<DialogFooter>
<DefaultButton onClick={this._dismissErrorDetails}>Close</DefaultButton>
</DialogFooter>
</Panel>
{loaded && children(data)}
{spinnersEnabled && <AsyncOverlay show={!done} label={strings.Loading} spinnerSize={spinnerSize} />}
{spinnersEnabled && <AsyncOverlay show={saving} label={saveLabel || strings.Saving} spinnerSize={spinnerSize} />}
</div>
);
} else {
return <></>;
}
}
}

View File

@ -0,0 +1,27 @@
import React from "react";
import { css, Overlay, Spinner, SpinnerSize } from '@fluentui/react';
import * as cstrings from "CommonStrings";
import styles from "./styles/AsyncOverlay.module.scss";
export interface IAsyncOverlayProps {
show: boolean;
label?: string;
className?: string;
spinnerSize?: SpinnerSize;
}
export const AsyncOverlay: React.FC<IAsyncOverlayProps> = (props: IAsyncOverlayProps) => {
const className: string = css(styles.asyncOverlay, props.className);
return (props.show &&
<Overlay className={className}>
<Spinner size={props.spinnerSize} label={props.label} />
</Overlay>
|| null
);
};
AsyncOverlay.defaultProps = {
label: cstrings.OneMoment,
spinnerSize: SpinnerSize.large
};

View File

@ -0,0 +1,73 @@
import { noop } from "lodash";
import moment, { Moment, months, monthsShort, weekdays, weekdaysMin } from "moment-timezone";
import React, { FC, useCallback, useRef } from "react";
import { ActionButton, Calendar, DayOfWeek, DateRangeType, Callout, DirectionalHint, FocusTrapZone, IIconProps } from '@fluentui/react';
import { useBoolean } from "@fluentui/react-hooks";
import { now } from "../Utils";
interface IProps {
buttonLabel: string;
dateRangeType?: DateRangeType;
disabled?: boolean;
iconProps?: IIconProps;
date?: Moment;
onSelectDate?: (date: Moment) => void;
}
export const CalendarDefaultStrings = {
months: months(),
shortMonths: monthsShort(),
days: weekdays(),
shortDays: weekdaysMin(),
goToToday: 'Go to today',
prevMonthAriaLabel: 'Go to previous month',
nextMonthAriaLabel: 'Go to next month',
prevYearAriaLabel: 'Go to previous year',
nextYearAriaLabel: 'Go to next year'
};
export const CalendarPicker: FC<IProps> = ({
buttonLabel,
dateRangeType = DateRangeType.Day,
disabled,
iconProps,
date = now(),
onSelectDate = noop
}) => {
const buttonRef = useRef<HTMLSpanElement>();
const [showCalendar, { setFalse: closeCalendar, toggle: toggleCalendar }] = useBoolean(false);
const calendarDate = date.clone().tz(moment.tz.guess(), true).toDate();
const onCalendarSelectDate = useCallback((d: Date) => {
onSelectDate(moment(d).tz(moment.tz.guess()));
closeCalendar()
}, [onSelectDate, closeCalendar]);
return (
<span ref={buttonRef}>
<ActionButton iconProps={iconProps} disabled={disabled} onClick={toggleCalendar}>
{buttonLabel}
</ActionButton>
{showCalendar &&
<Callout
setInitialFocus
isBeakVisible={false}
directionalHint={DirectionalHint.bottomCenter}
target={buttonRef.current}
onDismiss={closeCalendar}
>
<FocusTrapZone isClickableOutsideFocusTrap>
<Calendar
dateRangeType={dateRangeType}
onSelectDate={onCalendarSelectDate}
value={calendarDate}
firstDayOfWeek={DayOfWeek.Sunday}
strings={CalendarDefaultStrings}
/>
</FocusTrapZone>
</Callout>
}
</span>
);
};

View File

@ -0,0 +1,65 @@
import React, { FC, KeyboardEvent, SyntheticEvent, useCallback, useRef } from "react";
import { ColorPicker, Callout, Label, IColor } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
import { Color } from "../Color";
import styles from "./styles/CalloutColorPicker.module.scss";
interface IProps {
label?: string;
ariaLabel?: string;
required?: boolean;
hideAlpha?: boolean;
color: Color;
onChanged: (value: Color) => void;
}
export const CalloutColorPicker: FC<IProps> = ({
label,
ariaLabel,
color,
required,
hideAlpha = true,
onChanged
}) => {
const [isOpen, { toggle: toggleCallout, setFalse: closeCallout }] = useBoolean(false);
const colorPreviewRef = useRef<HTMLDivElement>();
const onColorPickerChange = useCallback((ev: SyntheticEvent, { str }: IColor) => {
onChanged(Color.parse(str));
}, [onChanged]);
const onColorPreviewKeyPress = useCallback((ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") toggleCallout();
}, [toggleCallout]);
return (
<div className={styles.calloutColorPicker}>
{label && <Label required={required}>{label}</Label>}
<div
aria-label={ariaLabel}
style={{ backgroundColor: color.toHexString() }}
className={styles.colorPreview}
ref={colorPreviewRef}
onClick={toggleCallout}
onKeyPress={onColorPreviewKeyPress}
tabIndex={0}
/>
{isOpen &&
<Callout
isBeakVisible={false}
onDismiss={closeCallout}
target={colorPreviewRef.current}
gapSpace={0}
>
<ColorPicker
showPreview
color={color.toCssString()}
alphaType="none"
onChange={onColorPickerChange}
/>
</Callout>
}
</div>
);
};

View File

@ -0,0 +1,56 @@
import React from "react";
import { DefaultButton, PrimaryButton, Dialog, DialogFooter, IModalProps } from "@fluentui/react";
import { IButtonStrings, IDialogStrings } from "../Localization";
import * as strings from "CommonStrings";
export interface IConfirmDialogProps {
show: boolean;
onAccept: () => void;
onReject: () => void;
disabled?: boolean;
strings?: IDialogStrings;
headingTextOverride?: string;
messageTextOverride?: string;
acceptButtonStringsOverride?: IButtonStrings;
rejectButtonStringsOverride?: IButtonStrings;
hideRejectButton?: boolean;
}
export const ConfirmDialog: React.FC<IConfirmDialogProps> = (props: IConfirmDialogProps) => {
const dialogStrings = props.strings || strings.ConfirmDialogDefaults;
const heading = props.headingTextOverride || dialogStrings.HeadingText;
const message = props.messageTextOverride || dialogStrings.MessageText;
const acceptButtonStrings = props.acceptButtonStringsOverride || dialogStrings.AcceptButton;
const rejectButtonStrings = props.rejectButtonStringsOverride || dialogStrings.RejectButton;
const disabled = props.disabled || false;
return (
<Dialog
hidden={!props.show}
modalProps={{
isBlocking: true
} as IModalProps}
dialogContentProps={{
showCloseButton: false,
title: heading,
subText: message
}}>
<DialogFooter>
<PrimaryButton
disabled={disabled}
text={acceptButtonStrings.Text}
secondaryText={acceptButtonStrings.Description}
onClick={props.onAccept} />
{!props.hideRejectButton &&
<DefaultButton
disabled={disabled}
text={rejectButtonStrings.Text}
secondaryText={rejectButtonStrings.Description}
onClick={props.onReject} />
}
</DialogFooter>
</Dialog>
);
};

View File

@ -0,0 +1,308 @@
import React, { Component, MutableRefObject, RefObject } from "react";
import { MessageBar, MessageBarType } from '@fluentui/react';
import { ErrorHandler } from "../ErrorHandler";
import { IComponent } from "../IComponent";
import { AsyncOverlay } from "./AsyncOverlay";
import { ConfirmDialog } from "./ConfirmDialog";
import * as cstrings from "CommonStrings";
export type UpdateDataCallback<T> = (update: (data: T) => void, callback?: () => any) => void;
export enum DataComponentMode {
ReadOnly,
Display,
Edit
}
export interface IDataComponentBase<T> extends IComponent {
valid(showValidationFeedback: boolean): boolean;
readonly(entity: T): Promise<void>;
display(entity?: T): Promise<void>;
edit(entity?: T): Promise<void>;
inDisplayMode: boolean;
inEditMode: boolean;
}
export interface IDataComponentBaseProps<T> {
componentRef?: RefObject<IDataComponentBase<T>>;
onDismissed?: () => void;
className?: string;
}
export interface IDataComponentBaseState<T> {
data: T;
mode: DataComponentMode;
showValidationFeedback: boolean;
submitting: boolean;
showConfirmDiscard: boolean;
showConfirmDelete: boolean;
errorMessage: string;
}
export abstract class DataComponentBase<T, P extends IDataComponentBaseProps<T>, S extends IDataComponentBaseState<T>> extends Component<P, S> implements IDataComponentBase<T> {
private _accept: () => void;
private _discard: () => void;
constructor(props: P) {
super(props);
this.state = this.resetState();
}
protected resetState(): S {
this._accept = () => { };
this._discard = () => { };
return {
data: null,
mode: DataComponentMode.Display,
showValidationFeedback: false,
submitting: false,
showConfirmDiscard: false,
showConfirmDelete: false,
errorMessage: null
} as S;
}
public componentDidMount() {
(this.props.componentRef as MutableRefObject<IDataComponentBase<T>>).current = this;
}
public componentWillUnmount(): void {
(this.props.componentRef as MutableRefObject<IDataComponentBase<T>>).current = null;
}
public componentShouldRender() {
this.forceUpdate();
}
protected get data(): T {
return this.state.data;
}
protected get isReadOnly(): boolean {
return this.state.mode === DataComponentMode.ReadOnly;
}
public get inDisplayMode(): boolean {
return this.state.mode === DataComponentMode.Display || this.state.mode === DataComponentMode.ReadOnly;
}
public get inEditMode(): boolean {
return this.state.mode === DataComponentMode.Edit;
}
public valid(showValidationFeedback: boolean = true): boolean {
this.setState({ showValidationFeedback });
return this.validate();
}
protected abstract validate(): boolean;
protected readonly updateField = (update: (data: T) => void, callback?: () => any): void => {
this.setState((prevState: S) => {
const data = prevState.data;
update(data);
return {
...prevState,
data
};
}, callback);
}
public readonly(entity: T): Promise<void> {
entity = entity || this.data;
this.setState({
data: entity,
mode: DataComponentMode.ReadOnly,
errorMessage: null
} as S);
return new Promise<void>((resolve, reject) => {
this._accept = resolve;
this._discard = reject;
});
}
public display(entity?: T): Promise<void> {
entity = entity || this.data;
this.setState({
data: entity,
mode: DataComponentMode.Display,
showValidationFeedback: false,
errorMessage: null
} as S);
return new Promise<void>((resolve, reject) => {
this._accept = resolve;
this._discard = reject;
});
}
public edit(entity?: T): Promise<void> {
entity = entity || this.data;
if (!this.isReadOnly) {
this.setState({
data: entity,
mode: DataComponentMode.Edit
} as S);
}
return new Promise<void>((resolve, reject) => {
this._accept = resolve;
this._discard = reject;
});
}
protected submit(successFn: () => void) {
if (this.valid()) {
this.submitting(true);
this.persistChanges(successFn);
} else {
this.error(cstrings.Validation.ValidationFailed);
}
}
protected submitting(val: boolean) {
this.setState({ submitting: val });
}
protected confirmDelete() {
this.setState({ showConfirmDelete: true });
}
protected delete() {
this.submitting(true);
this.persistChanges(() => {
this.onDeleted();
this.dismiss();
});
}
protected onDeleted() {
}
public confirmDiscard() {
this.discard();
}
public discard() {
this._discard();
this.dismiss();
}
public dismiss() {
if (this.data) {
this.setState(this.resetState());
if (this.props.onDismissed) {
this.props.onDismissed();
}
}
}
public error(msg: string = cstrings.GenericError) {
this.setState({
submitting: false,
errorMessage: msg
});
}
protected async persistChanges(successFn: () => void) {
try {
await this.persistChangesCore();
this.submitting(false);
this._accept();
successFn();
} catch (e) {
console.error(e);
this.error(await ErrorHandler.message(e));
}
}
protected abstract persistChangesCore(): Promise<void>;
public render() {
const onDiscard = () => this.discard();
const onDelete = () => this.delete();
const Header = () => this.renderHeader();
const Footer = () => this.renderFooter();
return (
<div>
{this.data && <Header />}
<div>
{this.state.errorMessage &&
<MessageBar role="alert" messageBarType={MessageBarType.error}>
{this.state.errorMessage}
</MessageBar>
}
{this.data && this.renderContent()}
<AsyncOverlay show={this.state.submitting} label={cstrings.Saving} />
</div>
{this.data && <Footer />}
<ConfirmDialog
show={this.state.showConfirmDiscard}
strings={cstrings.ConfirmDiscardDialog}
onAccept={onDiscard}
onReject={() => this.setState({ showConfirmDiscard: false })} />
<ConfirmDialog
show={this.state.showConfirmDelete}
strings={cstrings.ConfirmDeleteDialog}
disabled={this.state.submitting}
onAccept={onDelete}
onReject={() => this.setState({ showConfirmDelete: false })} />
</div>
);
}
protected renderHeader(): JSX.Element {
if (this.isReadOnly)
return this.renderReadOnlyHeader() || this.renderDisplayHeader();
else if (this.inDisplayMode)
return this.renderDisplayHeader();
else if (this.inEditMode)
return this.renderEditHeader();
}
protected renderReadOnlyHeader(): JSX.Element { return null; }
protected renderDisplayHeader(): JSX.Element { return null; }
protected renderEditHeader(): JSX.Element { return null; }
protected renderContent(): JSX.Element {
if (this.isReadOnly)
return this.renderReadOnlyContent() || this.renderDisplayContent();
else if (this.inDisplayMode)
return this.renderDisplayContent();
else if (this.inEditMode)
return this.renderEditContent();
}
protected renderReadOnlyContent(): JSX.Element { return null; }
protected renderDisplayContent(): JSX.Element { return null; }
protected renderEditContent(): JSX.Element { return null; }
protected renderFooter(): JSX.Element {
if (this.isReadOnly)
return this.renderReadOnlyFooter() || this.renderDisplayFooter();
else if (this.inDisplayMode)
return this.renderDisplayFooter();
else if (this.inEditMode)
return this.renderEditFooter();
}
protected renderReadOnlyFooter(): JSX.Element { return null; }
protected renderDisplayFooter(): JSX.Element { return null; }
protected renderEditFooter(): JSX.Element { return null; }
}

View File

@ -0,0 +1,320 @@
import React, { Component, MutableRefObject, RefObject } from "react";
import { css, Dialog, MessageBar, MessageBarType, IModalProps } from "@fluentui/react";
import { ErrorHandler } from "../ErrorHandler";
import { IComponent } from "../IComponent";
import { AsyncOverlay } from "./AsyncOverlay";
import { ConfirmDialog } from "./ConfirmDialog";
import * as cstrings from "CommonStrings";
import styles from "./styles/DataDialogBase.module.scss";
export type UpdateDataCallback<T> = (update: (data: T) => void, callback?: () => any) => void;
export enum DataDialogMode {
ReadOnly,
Display,
Edit
}
export interface IDataDialogBase<T> extends IComponent {
valid(showValidationFeedback: boolean): boolean;
readonly(entity: T): Promise<void>;
display(entity?: T): Promise<void>;
edit(entity?: T): Promise<void>;
inDisplayMode: boolean;
inEditMode: boolean;
}
export interface IDataDialogBaseProps<T> {
componentRef?: RefObject<IDataDialogBase<T>>;
onDismissed?: () => void;
title?: string;
className?: string;
showCloseButton?: boolean;
wide?: boolean;
}
export interface IDataDialogBaseState<T> {
hidden: boolean;
data: T;
mode: DataDialogMode;
showValidationFeedback: boolean;
submitting: boolean;
showConfirmDiscard: boolean;
showConfirmDelete: boolean;
errorMessage: string;
}
export abstract class DataDialogBase<T, P extends IDataDialogBaseProps<T>, S extends IDataDialogBaseState<T>> extends Component<P, S> implements IDataDialogBase<T> {
private _accept: () => void;
private _discard: () => void;
constructor(props: P) {
super(props);
this.state = this.resetState();
}
protected resetState(): S {
this._accept = () => { };
this._discard = () => { };
return {
hidden: true,
data: null,
mode: DataDialogMode.Display,
showValidationFeedback: false,
submitting: false,
showConfirmDiscard: false,
showConfirmDelete: false,
errorMessage: null
} as S;
}
public componentDidMount() {
(this.props.componentRef as MutableRefObject<IDataDialogBase<T>>).current = this;
}
public componentWillUnmount(): void {
(this.props.componentRef as MutableRefObject<IDataDialogBase<T>>).current = null;
}
public componentShouldRender() {
this.forceUpdate();
}
protected get title(): string {
return this.props.title;
}
protected get data(): T {
return this.state.data;
}
protected get isReadOnly(): boolean {
return this.state.mode === DataDialogMode.ReadOnly;
}
public get inDisplayMode(): boolean {
return this.state.mode === DataDialogMode.Display || this.state.mode === DataDialogMode.ReadOnly;
}
public get inEditMode(): boolean {
return this.state.mode === DataDialogMode.Edit;
}
public valid(showValidationFeedback: boolean = true): boolean {
this.setState({ showValidationFeedback });
return this.validate();
}
protected abstract validate(): boolean;
protected readonly updateField = (update: (data: T) => void, callback?: () => any): void => {
this.setState((prevState: S) => {
const data = prevState.data;
update(data);
return {
...prevState,
data
};
}, callback);
}
public readonly(entity: T): Promise<void> {
entity = entity || this.data;
this.setState({
hidden: false,
data: entity,
mode: DataDialogMode.ReadOnly,
errorMessage: null
} as S);
return new Promise<void>((resolve, reject) => {
this._accept = resolve;
this._discard = reject;
});
}
public display(entity?: T): Promise<void> {
entity = entity || this.data;
this.setState({
hidden: false,
data: entity,
mode: DataDialogMode.Display,
showValidationFeedback: false,
errorMessage: null
} as S);
return new Promise<void>((resolve, reject) => {
this._accept = resolve;
this._discard = reject;
});
}
public edit(entity?: T): Promise<void> {
entity = entity || this.data;
if (!this.isReadOnly) {
this.setState({
hidden: false,
data: entity,
mode: DataDialogMode.Edit
} as S);
}
return new Promise<void>((resolve, reject) => {
this._accept = resolve;
this._discard = reject;
});
}
protected submit(successFn: () => void) {
if (this.valid()) {
this.submitting(true);
this.persistChanges(successFn);
} else {
this.error(cstrings.Validation.ValidationFailed);
}
}
protected submitting(val: boolean) {
this.setState({ submitting: val });
}
protected confirmDelete() {
this.setState({ showConfirmDelete: true });
}
protected delete() {
this.submitting(true);
this.persistChanges(() => {
this.onDeleted();
this.dismiss();
});
}
protected onDeleted() {
}
public confirmDiscard() {
this.discard();
}
public discard() {
this._discard();
this.dismiss();
}
public dismiss() {
if (this.data) {
this.setState(this.resetState());
if (this.props.onDismissed) {
this.props.onDismissed();
}
}
}
public error(msg: string = cstrings.GenericError) {
this.setState({
submitting: false,
errorMessage: msg
});
}
protected async persistChanges(successFn: () => void) {
try {
await this.persistChangesCore();
this.submitting(false);
this._accept();
successFn();
} catch (e) {
console.error(e);
this.error(await ErrorHandler.message(e));
}
}
protected abstract persistChangesCore(): Promise<void>;
public render() {
const { className, wide, showCloseButton } = this.props;
const { hidden, submitting, showConfirmDiscard, showConfirmDelete, errorMessage } = this.state;
const onConfirmDiscard = () => this.confirmDiscard();
const onDiscard = () => this.discard();
const onDelete = () => this.delete();
const Footer = () => this.renderFooter();
return (
<div>
<Dialog
title={this.title}
modalProps={{
className: css(styles.dataDialogBase, { [styles.wide]: wide }, className),
isBlocking: true,
isDarkOverlay: true
} as IModalProps}
dialogContentProps={{ showCloseButton }}
hidden={hidden}
closeButtonAriaLabel={cstrings.Close}
onDismiss={onConfirmDiscard}>
<div>
{errorMessage &&
<MessageBar role="alert" messageBarType={MessageBarType.error}>
{errorMessage}
</MessageBar>
}
{this.data && this.renderContent()}
<AsyncOverlay show={submitting} label={cstrings.Saving} />
</div>
{this.data && <Footer />}
<ConfirmDialog
show={showConfirmDiscard}
strings={cstrings.ConfirmDiscardDialog}
onAccept={onDiscard}
onReject={() => this.setState({ showConfirmDiscard: false })} />
</Dialog>
<ConfirmDialog
show={showConfirmDelete}
strings={cstrings.ConfirmDeleteDialog}
onAccept={onDelete}
onReject={() => this.setState({ showConfirmDelete: false })} />
</div>
);
}
protected renderContent(): JSX.Element {
if (this.isReadOnly)
return this.renderReadOnlyContent() || this.renderDisplayContent();
else if (this.inDisplayMode)
return this.renderDisplayContent();
else if (this.inEditMode)
return this.renderEditContent();
}
protected renderReadOnlyContent(): JSX.Element { return null; }
protected renderDisplayContent(): JSX.Element { return null; }
protected renderEditContent(): JSX.Element { return null; }
protected renderFooter(): JSX.Element {
if (this.isReadOnly)
return this.renderReadOnlyFooter() || this.renderDisplayFooter();
else if (this.inDisplayMode)
return this.renderDisplayFooter();
else if (this.inEditMode)
return this.renderEditFooter();
}
protected renderReadOnlyFooter(): JSX.Element { return null; }
protected renderDisplayFooter(): JSX.Element { return null; }
protected renderEditFooter(): JSX.Element { return null; }
}

View File

@ -0,0 +1,410 @@
import { isEmpty } from "lodash";
import React, { Component, CSSProperties, FC, MutableRefObject, RefObject, useCallback } from "react";
import { css, CommandBar, IconButton, ICommandBarItemProps, MessageBar, MessageBarType, Panel, PanelType, Stack, StackItem, Text, useTheme, TooltipHost } from '@fluentui/react';
import { IComponent } from "common";
import { BackEventListener } from "../BackEventListener";
import { ErrorHandler } from "../ErrorHandler";
import { AsyncOverlay } from "./AsyncOverlay";
import { ConfirmDialog } from "./ConfirmDialog";
import * as cstrings from "CommonStrings";
import styles from "./styles/DataPanelBase.module.scss";
interface IPanelNavigationProps {
heading: JSX.Element;
headerCommands: ICommandBarItemProps[];
hasCloseButton: boolean;
onConfirmDiscard: () => void;
errorMessage: string | undefined;
}
const PanelNavigation: FC<IPanelNavigationProps> = ({
heading,
headerCommands,
hasCloseButton,
onConfirmDiscard,
errorMessage
}) => {
const hasHeaderCommands = !isEmpty(headerCommands);
const { semanticColors: { bodyBackground } } = useTheme();
const navigationStyle = useCallback(() => {
return {
backgroundColor: bodyBackground
} as CSSProperties;
}, [bodyBackground]);
return (
<div style={navigationStyle()}>
<Stack className={styles.headerCommands} horizontal verticalAlign="center" tokens={{ childrenGap: 16 }}>
<StackItem grow>
{hasHeaderCommands
? <CommandBar items={headerCommands} />
: heading
}
</StackItem>
{hasCloseButton &&
<StackItem>
<TooltipHost content={cstrings.Close}>
<IconButton onClick={onConfirmDiscard} iconProps={{ iconName: "Cancel" }} ariaLabel={cstrings.Close} />
</TooltipHost>
</StackItem>
}
</Stack>
{hasHeaderCommands && heading}
{errorMessage && <MessageBar role="alert" messageBarType={MessageBarType.error}>{errorMessage}</MessageBar>}
</div>
);
};
export type UpdateDataCallback<T> = (update: (data: T) => void, callback?: () => any) => void;
export enum DataPanelMode {
ReadOnly,
Display,
Edit
}
export interface IDataPanelBase<T> extends IComponent {
valid(showValidationFeedback: boolean): boolean;
readonly(entity: T): Promise<void>;
display(entity?: T): Promise<void>;
edit(entity?: T): Promise<void>;
inDisplayMode: boolean;
inEditMode: boolean;
}
export interface IDataPanelBaseProps<T> {
componentRef?: RefObject<IDataPanelBase<T>>;
onDismissed?: () => void;
title?: string;
className?: string;
hasCloseButton?: boolean;
panelType?: PanelType;
}
export interface IDataPanelBaseState<T> {
hidden: boolean;
data: T;
mode: DataPanelMode;
showValidationFeedback: boolean;
submitting: boolean;
showConfirmDiscard: boolean;
showConfirmDelete: boolean;
errorMessage: string;
}
export abstract class DataPanelBase<T, P extends IDataPanelBaseProps<T>, S extends IDataPanelBaseState<T>> extends Component<P, S> implements IDataPanelBase<T> {
private readonly _backEventListener = new BackEventListener(() => { if (this.data) this.confirmDiscard(); });
private _promise: Promise<void>;
private _accept: () => void;
private _discard: () => void;
constructor(props: P) {
super(props);
this.state = this.resetState();
}
protected resetState(): S {
this._resetPromise();
return {
hidden: true,
data: null,
mode: DataPanelMode.Display,
showValidationFeedback: false,
submitting: false,
showConfirmDiscard: false,
showConfirmDelete: false,
errorMessage: null
} as S;
}
public componentDidMount() {
(this.props.componentRef as MutableRefObject<IDataPanelBase<T>>).current = this;
}
public componentWillUnmount(): void {
(this.props.componentRef as MutableRefObject<IDataPanelBase<T>>).current = null;
this._backEventListener.cleanup();
}
public componentShouldRender() {
this.forceUpdate();
}
protected get title(): string {
return this.props.title;
}
protected get data(): T {
return this.state.data;
}
protected get isReadOnly(): boolean {
return this.state.mode === DataPanelMode.ReadOnly;
}
public get inDisplayMode(): boolean {
return this.state.mode === DataPanelMode.Display || this.state.mode === DataPanelMode.ReadOnly;
}
public get inEditMode(): boolean {
return this.state.mode === DataPanelMode.Edit;
}
public readonly valid = (showValidationFeedback: boolean = true): boolean => {
this.setState({ showValidationFeedback });
return this.validate();
}
protected abstract validate(): boolean;
protected readonly updateField = (update: (data: T) => void, callback?: () => any): void => {
this.setState((prevState: S) => {
const data = prevState.data;
update(data);
return {
...prevState,
data
};
}, callback);
}
public readonly(entity: T, resetPromise: boolean = true): Promise<void> {
if (entity && entity !== this.data && resetPromise) this._resetPromise();
entity = entity || this.data;
this.setState({
hidden: false,
data: entity,
mode: DataPanelMode.ReadOnly,
errorMessage: null
});
this._backEventListener.listenForBack();
return this._promise;
}
public display(entity?: T, resetPromise: boolean = true): Promise<void> {
if (entity && entity !== this.data && resetPromise) this._resetPromise();
entity = entity || this.data;
this.setState({
hidden: false,
data: entity,
mode: DataPanelMode.Display,
showValidationFeedback: false,
errorMessage: null
});
this._backEventListener.listenForBack();
return this._promise;
}
public edit(entity?: T, resetPromise: boolean = true): Promise<void> {
if (entity && entity !== this.data && resetPromise) this._resetPromise();
entity = entity || this.data;
if (!this.isReadOnly) {
this.setState({
hidden: false,
data: entity,
mode: DataPanelMode.Edit
});
}
this._backEventListener.listenForBack();
return this._promise;
}
private _resetPromise() {
this._promise = new Promise((resolve, reject) => {
this._accept = resolve;
this._discard = reject;
});
}
protected submit(successFn: () => void) {
if (this.valid()) {
this.submitting(true);
this.persistChanges(successFn);
} else {
this.error(cstrings.Validation.ValidationFailed);
}
}
protected submitting(val: boolean) {
this.setState({ submitting: val });
}
protected confirmDelete() {
this.setState({ showConfirmDelete: true });
}
protected delete() {
this.submitting(true);
this.persistChanges(() => {
this.onDeleted();
this.dismiss();
});
}
protected onDeleted() {
}
public confirmDiscard() {
this.discard();
}
public discard() {
this._discard();
this.dismiss();
}
public dismiss() {
if (this.data) {
this.setState(this.resetState());
if (this.props.onDismissed) {
this.props.onDismissed();
}
}
this._backEventListener.cancelListeningForBack();
}
public error(msg: string = cstrings.GenericError) {
this.setState({
submitting: false,
errorMessage: msg
});
}
protected customSavingLabel(): string {
return cstrings.Saving;
}
protected async persistChanges(successFn: () => void) {
try {
await this.persistChangesCore();
this.submitting(false);
this._accept();
successFn();
} catch (e) {
console.error(e);
this.error(await ErrorHandler.message(e));
}
}
protected abstract persistChangesCore(): Promise<void>;
public render() {
const { className, hasCloseButton, panelType } = this.props;
const { hidden, submitting, showConfirmDiscard, showConfirmDelete, errorMessage } = this.state;
const onDiscard = () => this.discard();
const onConfirmDiscard = () => this.confirmDiscard();
const onDelete = () => this.delete();
const headerCommands = this.data ? this.buildHeaderCommands() : [];
const Heading = () => this.data && this.renderHeader() ||
<Text block className={styles.heading} role="heading" aria-level={1}>{this.title}</Text>;
const footerContent = this.data && this.renderFooterContent();
const hasFooterContent = !!footerContent;
return (
<Panel
type={panelType !== undefined ? panelType : PanelType.medium}
isOpen={!hidden}
isBlocking={true}
className={css(styles.panel, className)}
onRenderNavigation={() =>
<PanelNavigation
heading={<Heading />}
headerCommands={headerCommands}
hasCloseButton={hasCloseButton}
onConfirmDiscard={onConfirmDiscard}
errorMessage={errorMessage}
/>
}
onRenderFooterContent={hasFooterContent ? () => footerContent : undefined}
isFooterAtBottom={hasFooterContent}
>
{this.data && this.renderContent()}
<AsyncOverlay show={submitting} label={this.customSavingLabel()} />
<ConfirmDialog
show={showConfirmDiscard}
strings={cstrings.ConfirmDiscardDialog}
onAccept={onDiscard}
onReject={() => { this.setState({ showConfirmDiscard: false }); this._backEventListener.listenForBack(); }} />
<ConfirmDialog
show={showConfirmDelete}
strings={cstrings.ConfirmDeleteDialog}
onAccept={onDelete}
onReject={() => this.setState({ showConfirmDelete: false })} />
</Panel>
);
}
protected renderHeader(): JSX.Element {
if (this.isReadOnly)
return this.renderReadOnlyHeader() || this.renderDisplayHeader();
else if (this.inDisplayMode)
return this.renderDisplayHeader();
else if (this.inEditMode)
return this.renderEditHeader() || this.renderDisplayHeader();
}
protected renderReadOnlyHeader(): JSX.Element { return null; }
protected renderDisplayHeader(): JSX.Element { return null; }
protected renderEditHeader(): JSX.Element { return null; }
protected buildHeaderCommands(): ICommandBarItemProps[] {
if (this.isReadOnly)
return this.buildReadOnlyHeaderCommands() || this.buildDisplayHeaderCommands() || [];
else if (this.inDisplayMode)
return this.buildDisplayHeaderCommands() || [];
else if (this.inEditMode)
return this.buildEditHeaderCommands() || [];
}
protected buildReadOnlyHeaderCommands(): ICommandBarItemProps[] { return null; }
protected buildDisplayHeaderCommands(): ICommandBarItemProps[] { return null; }
protected buildEditHeaderCommands(): ICommandBarItemProps[] { return null; }
protected renderContent(): JSX.Element {
if (this.isReadOnly)
return this.renderReadOnlyContent() || this.renderDisplayContent();
else if (this.inDisplayMode)
return this.renderDisplayContent();
else if (this.inEditMode)
return this.renderEditContent();
}
protected renderReadOnlyContent(): JSX.Element { return null; }
protected renderDisplayContent(): JSX.Element { return null; }
protected renderEditContent(): JSX.Element { return null; }
protected renderFooterContent(): JSX.Element {
if (this.isReadOnly)
return this.renderReadOnlyFooterContent() || this.renderDisplayFooterContent();
else if (this.inDisplayMode)
return this.renderDisplayFooterContent();
else if (this.inEditMode)
return this.renderEditFooterContent();
}
protected renderReadOnlyFooterContent(): JSX.Element { return null; }
protected renderDisplayFooterContent(): JSX.Element { return null; }
protected renderEditFooterContent(): JSX.Element { return null; }
}

View File

@ -0,0 +1,60 @@
import { Moment } from "moment-timezone";
import React, { FC } from "react";
import { IconButton, DateRangeType, Stack, IStyle, IIconProps, StackItem, FocusZone } from "@fluentui/react";
import { CalendarPicker } from "./CalendarPicker";
import { DateRotator as strings } from 'CommonStrings';
const styles = {
root: {
maxWidth: 300,
width: '100%'
} as IStyle
};
const defaultPreviousIconProps: IIconProps = { iconName: 'ChevronLeft' };
const defaultNextIconProps: IIconProps = { iconName: 'ChevronRight' };
interface IProps {
date: Moment;
dateString: string;
dateRangeType?: DateRangeType;
previousIconProps?: IIconProps;
nextIconProps?: IIconProps;
onPrevious: () => void;
onNext: () => void;
onDateChanged: (date: Moment) => void;
}
export const DateRotator: FC<IProps> = ({
date,
dateString,
dateRangeType,
previousIconProps = defaultPreviousIconProps,
nextIconProps = defaultNextIconProps,
onPrevious,
onNext,
onDateChanged
}) =>
<FocusZone>
<Stack horizontal horizontalAlign="space-between" verticalAlign="center" styles={styles}>
<IconButton
iconProps={previousIconProps}
title={strings.PreviousDateButton.Text}
onClick={onPrevious}
/>
<IconButton
iconProps={nextIconProps}
title={strings.NextDateButton.Text}
onClick={onNext}
/>
<StackItem grow align="center">
<CalendarPicker
buttonLabel={dateString}
date={date}
dateRangeType={dateRangeType}
onSelectDate={onDateChanged}
/>
</StackItem>
</Stack>
</FocusZone>

View File

@ -0,0 +1,119 @@
import { isEqual } from 'lodash';
import { IAsyncData } from 'common';
import { Entity } from '../Entity';
import { DataComponentBase, IDataComponentBase, IDataComponentBaseProps, IDataComponentBaseState, DataComponentMode, UpdateDataCallback } from "./DataComponentBase";
export {
IDataComponentBase,
IDataComponentBaseState,
DataComponentMode,
UpdateDataCallback
};
export interface IEntityComponentProps<T extends Entity<any, any>> extends IDataComponentBaseProps<T> {
asyncWatchers?: IAsyncData<any>[];
}
export abstract class EntityComponentBase<T extends Entity<any, any>, P extends IEntityComponentProps<T>, S extends IDataComponentBaseState<T>> extends DataComponentBase<T, P, S> {
constructor(props: P) {
super(props);
this.state = this.resetState();
}
public componentDidMount(): void {
super.componentDidMount();
const { asyncWatchers } = this.props;
(asyncWatchers || []).map((async: IAsyncData<any>) => async.registerComponentForUpdates(this));
}
public componentWillUnmount(): void {
super.componentWillUnmount();
const { asyncWatchers } = this.props;
(asyncWatchers || []).map((async: IAsyncData<any>) => async.unregisterComponentForUpdates(this));
}
public componentDidUpdate(prevProps: Readonly<P>, prevState: Readonly<S>, snapshot?: any): void {
const { asyncWatchers: prevAsyncWatchers } = prevProps;
const { asyncWatchers: nextAsyncWatchers } = this.props;
if (!isEqual(prevAsyncWatchers, nextAsyncWatchers)) {
(prevAsyncWatchers || []).map((async: IAsyncData<any>) => async.unregisterComponentForUpdates(this));
(nextAsyncWatchers || []).map((async: IAsyncData<any>) => async.registerComponentForUpdates(this));
}
}
protected get entity(): T {
return this.data;
}
protected get isNew(): boolean {
return this.data && this.data.isNew;
}
protected hasChanges(): boolean {
return this.data && this.data.hasChanges();
}
protected validate(): boolean {
return this.data.valid();
}
public readonly(entity: T): Promise<void> {
entity = entity || this.entity;
entity.revert();
return super.readonly(entity);
}
public display(entity?: T): Promise<void> {
entity = entity || this.entity;
entity.revert();
return super.display(entity);
}
public edit(entity?: T): Promise<void> {
entity = entity || this.entity;
if (!this.isReadOnly) {
entity.snapshot();
}
return super.edit(entity);
}
public submit(successFn: () => void) {
super.submit(() => {
this.data.immortalize();
successFn();
});
}
public delete() {
this.markEntityDeleted();
super.delete();
}
protected onDeleted() {
this.entity.immortalize();
}
protected markEntityDeleted() {
this.entity.snapshot();
this.entity.delete();
}
public confirmDiscard() {
if (this.hasChanges() && !this.isNew) {
this.setState({ showConfirmDiscard: true });
} else {
this.discard();
}
}
public discard() {
if (this.entity) {
this.updateField(
entity => entity.revert(),
() => super.discard()
);
}
}
}

View File

@ -0,0 +1,120 @@
import { isEqual } from 'lodash';
import { IAsyncData } from 'common';
import { Entity } from '../Entity';
import { DataDialogBase, IDataDialogBase, IDataDialogBaseProps, IDataDialogBaseState, DataDialogMode, UpdateDataCallback } from "./DataDialogBase";
export {
IDataDialogBase,
IDataDialogBaseState,
DataDialogMode,
UpdateDataCallback
};
export interface IEntityDialogProps<T extends Entity<any, any>> extends IDataDialogBaseProps<T> {
asyncWatchers?: IAsyncData<any>[];
}
export abstract class EntityDialogBase<T extends Entity<any, any>, P extends IEntityDialogProps<T>, S extends IDataDialogBaseState<T>> extends DataDialogBase<T, P, S> {
constructor(props: P) {
super(props);
this.state = this.resetState();
}
public componentDidMount(): void {
super.componentDidMount();
const { asyncWatchers } = this.props;
(asyncWatchers || []).map((async: IAsyncData<any>) => async.registerComponentForUpdates(this));
}
public componentWillUnmount(): void {
super.componentWillUnmount();
const { asyncWatchers } = this.props;
(asyncWatchers || []).map((async: IAsyncData<any>) => async.unregisterComponentForUpdates(this));
}
public componentDidUpdate(prevProps: Readonly<P>, prevState: Readonly<S>, snapshot?: any): void {
const { asyncWatchers: prevAsyncWatchers } = prevProps;
const { asyncWatchers: nextAsyncWatchers } = this.props;
if (!isEqual(prevAsyncWatchers, nextAsyncWatchers)) {
(prevAsyncWatchers || []).map((async: IAsyncData<any>) => async.unregisterComponentForUpdates(this));
(nextAsyncWatchers || []).map((async: IAsyncData<any>) => async.registerComponentForUpdates(this));
}
}
protected get entity(): T {
return this.data;
}
protected get isNew(): boolean {
return this.data && this.data.isNew;
}
protected hasChanges(): boolean {
return this.data && this.data.hasChanges();
}
protected validate(): boolean {
return this.data.valid();
}
public readonly(entity: T): Promise<void> {
entity = entity || this.entity;
entity.revert();
return super.readonly(entity);
}
public display(entity?: T): Promise<void> {
entity = entity || this.entity;
entity.revert();
return super.display(entity);
}
public edit(entity?: T): Promise<void> {
entity = entity || this.entity;
if (!this.isReadOnly) {
entity.snapshot();
}
return super.edit(entity);
}
public submit(successFn: () => void) {
super.submit(() => {
this.data.immortalize();
successFn();
});
}
public delete() {
this.markEntityDeleted();
super.delete();
}
protected onDeleted() {
this.entity.immortalize();
}
protected markEntityDeleted() {
this.entity.snapshot();
this.entity.delete();
}
public confirmDiscard() {
if (this.hasChanges() && !this.isNew) {
this.setState({ showConfirmDiscard: true });
} else {
this.discard();
}
}
public discard() {
if (this.entity) {
this.updateField(
entity => entity.revert(),
() => super.discard()
);
}
}
}

View File

@ -0,0 +1,132 @@
import { isEqual } from 'lodash';
import { IAsyncData } from 'common';
import { Entity } from '../Entity';
import { DataPanelBase, IDataPanelBase, IDataPanelBaseProps, IDataPanelBaseState, DataPanelMode, UpdateDataCallback } from "./DataPanelBase";
export {
IDataPanelBase,
IDataPanelBaseState,
DataPanelMode,
UpdateDataCallback
};
export interface IEntityPanelProps<T extends Entity<any, any>> extends IDataPanelBaseProps<T> {
asyncWatchers?: IAsyncData<any>[];
}
export abstract class EntityPanelBase<T extends Entity<any, any>, P extends IEntityPanelProps<T>, S extends IDataPanelBaseState<T>> extends DataPanelBase<T, P, S> {
constructor(props: P) {
super(props);
this.state = this.resetState();
}
public componentDidMount(): void {
super.componentDidMount();
const { asyncWatchers } = this.props;
(asyncWatchers || []).map((async: IAsyncData<any>) => async.registerComponentForUpdates(this));
}
public componentWillUnmount(): void {
super.componentWillUnmount();
const { asyncWatchers } = this.props;
(asyncWatchers || []).map((async: IAsyncData<any>) => async.unregisterComponentForUpdates(this));
}
public componentDidUpdate(prevProps: Readonly<P>, prevState: Readonly<S>, snapshot?: any): void {
const { asyncWatchers: prevAsyncWatchers } = prevProps;
const { asyncWatchers: nextAsyncWatchers } = this.props;
if (!isEqual(prevAsyncWatchers, nextAsyncWatchers)) {
(prevAsyncWatchers || []).map((async: IAsyncData<any>) => async.unregisterComponentForUpdates(this));
(nextAsyncWatchers || []).map((async: IAsyncData<any>) => async.registerComponentForUpdates(this));
}
}
protected get entity(): T {
return this.data;
}
protected get isNew(): boolean {
return this.data && this.data.isNew;
}
protected hasChanges(): boolean {
return this.data && this.data.hasChanges();
}
protected readonly updateField = (update: (data: T) => void, callback?: () => any): void => {
this.setState((prevState: S) => {
const data = prevState.data;
update(data);
return {
...prevState,
data
};
}, callback);
}
protected validate(): boolean {
return this.data.valid();
}
public readonly(entity: T, resetPromise?: boolean): Promise<void> {
entity = entity || this.entity;
entity.revert();
return super.readonly(entity, resetPromise);
}
public display(entity?: T, resetPromise?: boolean): Promise<void> {
entity = entity || this.entity;
entity.revert();
return super.display(entity, resetPromise);
}
public edit(entity?: T, resetPromise?: boolean): Promise<void> {
entity = entity || this.entity;
if (!this.isReadOnly) {
entity.snapshot();
}
return super.edit(entity, resetPromise);
}
public submit(successFn: () => void) {
super.submit(() => {
this.data.immortalize();
successFn();
});
}
public delete() {
this.markEntityDeleted();
super.delete();
}
protected onDeleted() {
this.entity.immortalize();
}
protected markEntityDeleted() {
this.entity.snapshot();
this.entity.delete();
}
public confirmDiscard() {
if (this.hasChanges() && !this.isNew) {
this.setState({ showConfirmDiscard: true });
} else {
this.discard();
}
}
public discard() {
if (this.entity) {
this.updateField(
entity => entity.revert(),
() => super.discard()
);
}
}
}

View File

@ -0,0 +1,26 @@
import React, { CSSProperties, FC, ReactNode } from 'react';
import { TooltipHost, ITooltipHostProps, Text } from '@fluentui/react';
import { InfoIcon } from '@fluentui/react-icons-mdl2';
const infoIconStyle: CSSProperties = {
fontSize: 12,
marginLeft: 4
};
interface IProps extends ITooltipHostProps {
text: string;
hideIcon?: boolean;
tooltipHostProps?: ITooltipHostProps;
children: ReactNode;
}
export const InfoTooltip: FC<IProps> = ({
text,
hideIcon = false,
tooltipHostProps,
children
}: IProps) =>
<TooltipHost {...tooltipHostProps} content={text}>
{children}
{text && !hideIcon && <Text><InfoIcon style={infoIconStyle} tabIndex={0} /></Text>}
</TooltipHost>

View File

@ -0,0 +1,72 @@
import React from "react";
import { Label, TextField, TooltipHost, ITooltipProps } from '@fluentui/react';
import { InfoSolidIcon } from "@fluentui/react-icons-mdl2";
import styles from './styles/LengthLimitedTextfield.module.scss';
export enum CharacterLimitLabelPosition {
Top,
Bottom
}
interface IProps {
label: string;
value: string;
placeholder?: string;
tooltipProps?: ITooltipProps;
tooltipText?: string;
characterLimit: number;
autoFocus?: boolean;
required?: boolean;
multiline?: boolean;
disabled?: boolean;
readonly?: boolean;
rows?: number;
characterLimitLabelPosition?: CharacterLimitLabelPosition;
onChanged: (newValue: string) => void;
}
const renderLabel = (label: string, required: boolean, tooltipText: string, tooltipProps: ITooltipProps, remainingCharCount: number, showCharacterLimitLabel: boolean) => {
return (
<div className={styles.labelContainer}>
<Label required={required}>{label}</Label>
<TooltipHost content={tooltipProps ? undefined : tooltipText} tooltipProps={tooltipProps} calloutProps={{ gapSpace: 0 }}>
<InfoSolidIcon aria-label={tooltipText} tabIndex={0} className={styles.toolTipIcon} />
</TooltipHost>
{showCharacterLimitLabel &&
<Label className={styles.remainingCharCountTop}>({remainingCharCount} characters left)</Label>
}
</div>
);
};
export const LengthLimitedTextField: React.FC<IProps> = (props: IProps) => {
const text = props.value;
const remainingCharCount = Math.max(props.characterLimit - text.length, 0);
const onRenderLabel = () => renderLabel(props.label, props.required, props.tooltipText, props.tooltipProps, remainingCharCount, props.characterLimitLabelPosition === CharacterLimitLabelPosition.Top);
return (
<div className={styles.lengthLimitedTextField}>
<TextField
value={text}
ariaLabel={props.required ? props.label + ". Required." : props.label}
onRenderLabel={onRenderLabel}
placeholder={props.placeholder}
multiline={props.multiline}
maxLength={props.characterLimit}
rows={props.rows}
autoFocus={props.autoFocus}
onChange={(ev, val) => props.onChanged(val)}
disabled={props.disabled}
readOnly={props.readonly}
/>
{props.characterLimitLabelPosition === CharacterLimitLabelPosition.Bottom &&
<Label className={styles.remainingCharCountBottom}>{remainingCharCount} characters left</Label>
}
</div>
);
};
LengthLimitedTextField.defaultProps = {
characterLimitLabelPosition: CharacterLimitLabelPosition.Bottom
};

View File

@ -0,0 +1,81 @@
import { duration, Duration } from "moment-timezone";
import React, { FC } from "react";
import { Dropdown, IDropdownOption, Label } from "@fluentui/react";
import { ResponsiveGrid, GridRow, GridCol } from "./ResponsiveGrid";
interface IProps {
label?: string;
value: Duration;
className?: string;
onChanged: (val: Duration) => void;
}
const hoursDropDownOptions: IDropdownOption[] = [
{ key: 0, text: '-' },
{ key: 1, text: '1 hour' },
{ key: 2, text: '2 hours' },
{ key: 3, text: '3 hours' },
{ key: 4, text: '4 hours' },
{ key: 5, text: '5 hours' },
{ key: 6, text: '6 hours' },
{ key: 7, text: '7 hours' },
{ key: 8, text: '8 hours' }
];
const minutesDropDownOptions: IDropdownOption[] = [
{ key: 0, text: '0 minutes' },
{ key: 5, text: '5 minutes' },
{ key: 10, text: '10 minutes' },
{ key: 15, text: '15 minutes' },
{ key: 20, text: '20 minutes' },
{ key: 25, text: '25 minutes' },
{ key: 30, text: '30 minutes' },
{ key: 35, text: '35 minutes' },
{ key: 40, text: '40 minutes' },
{ key: 45, text: '45 minutes' },
{ key: 50, text: '50 minutes' },
{ key: 55, text: '55 minutes' },
];
export const LengthOfTimePicker: FC<IProps> = ({ label, value, className, onChanged }) => {
const hours = value.isValid() ? Math.min(value.hours(), 8) : 0;
const minutes = value.isValid() ? Math.floor(value.minutes() / 5) * 5 : 0; // round down to the closest 5-minute increment
const onHoursChanged = (option: IDropdownOption) => {
const newHours = option.key as number;
onChanged(duration({ hours: newHours, minutes }));
};
const onMinutesChanged = (option: IDropdownOption) => {
const newMinutes = option.key as number;
onChanged(duration({ hours, minutes: newMinutes }));
};
return (
<ResponsiveGrid className={className}>
{label &&
<GridRow>
<GridCol><Label>{label}</Label></GridCol>
</GridRow>
}
<GridRow>
<GridCol sm={12} lg={5}>
<Dropdown
aria-label={"hours"}
selectedKey={hours}
options={hoursDropDownOptions}
onChange={(ev, opt) => onHoursChanged(opt)}
/>
</GridCol>
<GridCol sm={12} lg={7}>
<Dropdown
aria-label={"minutes"}
selectedKey={minutes}
options={minutesDropDownOptions}
onChange={(ev, opt) => onMinutesChanged(opt)}
/>
</GridCol>
</GridRow>
</ResponsiveGrid>
);
};

View File

@ -0,0 +1,56 @@
import React, { FormEvent, useCallback } from 'react';
import { Checkbox, ICheckboxProps, Stack } from '@fluentui/react';
import { ValidationRule, PropsOfType } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import LiveUpdate from './LiveUpdate';
import { getCurrentValue, LiveType, setValue } from './LiveUtils';
import { Validation } from './Validation';
type DataType = boolean;
interface IProps<E extends ListItemEntity<any>, P extends PropsOfType<E, T>, T extends DataType> extends ICheckboxProps {
entity: E;
propertyName: P;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveCheckbox = <E extends ListItemEntity<any>, P extends PropsOfType<E, T>, T extends DataType>(props: IProps<E, P, T>) => {
const {
entity,
propertyName,
rules,
showValidationFeedback,
label,
ariaLabel = label,
updateField
} = props;
const value = getCurrentValue(entity, propertyName) as T;
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, val)), [updateField, propertyName]);
const onChange = useCallback((ev: FormEvent, checked: boolean) => updateField(e => setValue(e, propertyName, checked as LiveType<E, P>)), [updateField, propertyName]);
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} updateValue={updateValue}>
{(renderLiveUpdateMark) =>
<Checkbox
{...props}
ariaLabel={ariaLabel}
onRenderLabel={(checkboxProps, defaultRender) => <>
<Stack horizontal>
{defaultRender(checkboxProps)}
{renderLiveUpdateMark()}
</Stack>
</>}
checked={value}
onChange={onChange}
/>
}
</LiveUpdate>
</Validation>
);
};
export default LiveCheckbox;

View File

@ -0,0 +1,72 @@
import React, { ReactNode, useCallback, useMemo } from 'react';
import { ChoiceGroup, IChoiceGroupProps, Stack, Label, IChoiceGroupOption, useTheme, IChoiceGroupOptionStyles, concatStyleSets } from '@fluentui/react';
import { ValidationRule } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import LiveUpdate from './LiveUpdate';
import { getCurrentValue, LiveType, setValue } from './LiveUtils';
import { Validation } from './Validation';
interface IProps<E extends ListItemEntity<any>, P extends keyof E> extends IChoiceGroupProps {
entity: E;
propertyName: P;
getKeyFromValue: (val: LiveType<E, P>) => string;
getTextFromValue: (val: LiveType<E, P>) => string;
getValueFromKey: (key: string) => LiveType<E, P>;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
updateField: (update: (data: E) => void, callback?: () => any) => void;
renderValue?: (val: LiveType<E, P>) => ReactNode;
}
const LiveChoiceGroup = <E extends ListItemEntity<any>, P extends keyof E>(props: IProps<E, P>) => {
const {
entity,
propertyName,
getKeyFromValue,
getTextFromValue,
getValueFromKey,
rules,
showValidationFeedback,
label,
required,
updateField,
renderValue,
options
} = props;
const value = getCurrentValue(entity, propertyName);
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, val)), [updateField, propertyName]);
const renderValueCallback = useCallback((val: LiveType<E, P>) => <>{getTextFromValue(val)}</>, [getTextFromValue]);
const localRenderValue = renderValue || renderValueCallback;
const onChange = useCallback((ev, val: IChoiceGroupOption) => updateField(e => setValue(e, propertyName, getValueFromKey(val.key))), [updateField, propertyName, getValueFromKey]);
const { palette: { neutralLighterAlt } } = useTheme();
const fixHighContrastThemeStyle = useMemo(() => {
return {
root: { backgroundColor: neutralLighterAlt }
} as IChoiceGroupOptionStyles;
}, [neutralLighterAlt]);
options.forEach(option => option.styles = concatStyleSets(fixHighContrastThemeStyle, option.styles));
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} updateValue={updateValue} renderValue={localRenderValue}>
{(renderLiveUpdateMark) => <>
<Stack horizontal>
<Label required={required}>{label}</Label>
{renderLiveUpdateMark()}
</Stack>
<ChoiceGroup
{...props}
label={undefined}
selectedKey={getKeyFromValue(value)}
onChange={onChange}
/>
</>}
</LiveUpdate>
</Validation>
);
};
export default LiveChoiceGroup;

View File

@ -0,0 +1,74 @@
import React, { FormEvent, useCallback, useEffect, useRef } from 'react';
import { IComboBoxProps, ComboBox, Stack, IComboBox, IComboBoxOption, } from '@fluentui/react';
import { PropsOfType, ValidationRule } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import LiveUpdate from './LiveUpdate';
import { getCurrentValue, LiveType, setValue } from './LiveUtils';
import { Validation } from './Validation';
interface IProps<E extends ListItemEntity<any>, P extends PropsOfType<E, string>> extends IComboBoxProps {
entity: E;
propertyName: P;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
autoFocus?: boolean;
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveComboBox = <E extends ListItemEntity<any>, P extends PropsOfType<E, string>>(props: IProps<E, P>) => {
const {
entity,
propertyName,
rules,
showValidationFeedback,
autoFocus,
label,
options,
ariaLabel = label,
updateField
} = props;
const value = getCurrentValue(entity, propertyName) as string;
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, val)), [updateField, propertyName]);
const renderValue = useCallback((val: LiveType<E, P>) => <>{val || '-'}</>, []);
const onChange = useCallback((ev: FormEvent<IComboBox>, option: IComboBoxOption, index: number, value: string) => updateField(e => {
let newValue = '';
if (option)
newValue = option.text;
else if (value)
newValue = value;
setValue(e, propertyName, newValue as LiveType<E, P>);
}), [updateField, propertyName]);
const dropDownRef = useRef<IComboBox>();
useEffect(() => { if (autoFocus) dropDownRef.current?.focus(); }, [autoFocus]);
const amendedOptions = options.some(opt => opt.key === value)
? options
: [{ key: value, text: value }, ...options]
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} updateValue={updateValue} renderValue={renderValue}>
{(renderLiveUpdateMark) =>
<ComboBox
{...props}
options={amendedOptions}
componentRef={dropDownRef}
ariaLabel={ariaLabel}
onRenderLabel={(textFieldProps, defaultRender) =>
<Stack horizontal>
{defaultRender(textFieldProps)}
{renderLiveUpdateMark()}
</Stack>}
selectedKey={value}
onChange={onChange}
/>
}
</LiveUpdate>
</Validation>
);
};
export default LiveComboBox;

View File

@ -0,0 +1,65 @@
import moment, { Moment } from 'moment-timezone';
import React, { useCallback } from 'react';
import { DatePicker, IDatePickerProps, Label, Stack } from '@fluentui/react';
import { ValidationRule, PropsOfType } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import LiveUpdate from './LiveUpdate';
import { getCurrentValue, LiveType, setValue } from './LiveUtils';
import { Validation } from './Validation';
type DataType = Moment;
interface IProps<E extends ListItemEntity<any>, P extends PropsOfType<E, T>, T extends DataType> extends Omit<IDatePickerProps, 'value' | 'onSelectDate' | 'formatDate' | 'isRequired'> {
entity: E;
propertyName: P;
required?: boolean;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
formatMoment?: (date?: Moment) => string;
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveDatePicker = <E extends ListItemEntity<any>, P extends PropsOfType<E, T>, T extends DataType>(props: IProps<E, P, T>) => {
const {
entity,
propertyName,
rules,
showValidationFeedback,
label,
ariaLabel = label,
required,
formatMoment = date => date?.isValid() ? date.format('l') : '',
updateField
} = props;
const value = getCurrentValue(entity, propertyName) as T;
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, val)), [updateField, propertyName]);
const renderValue = useCallback((val: LiveType<E, P>) => <span>{(val as DataType)?.isValid() ? (val as DataType).format('dddd, MMMM DD, YYYY') : ''}</span>, []);
const formatDate = useCallback((val: Date) => formatMoment(moment(val)), [formatMoment]);
const onChange = useCallback((value: Date) => updateField(e => setValue(e, propertyName, moment(value) as LiveType<E, P>)), [updateField, propertyName]);
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} updateValue={updateValue} renderValue={renderValue}>
{(renderLiveUpdateMark) => <>
{label && <Stack horizontal>
<Label required={required}>{label}</Label>
{renderLiveUpdateMark()}
</Stack>}
<DatePicker
{...props}
label={undefined}
ariaLabel={ariaLabel}
isRequired={!label && required}
formatDate={formatDate}
value={value?.isValid() && value?.toDate()}
onSelectDate={onChange}
/>
{!label && renderLiveUpdateMark()}
</>}
</LiveUpdate>
</Validation>
);
};
export default LiveDatePicker;

View File

@ -0,0 +1,82 @@
import { first } from 'lodash';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { IDropdownProps, Dropdown, Stack, IDropdownOption, IDropdown } from '@fluentui/react';
import { ValidationRule } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import { InfoTooltip } from './InfoTooltip';
import LiveUpdate, { ITransformer, NonTransformer } from './LiveUpdate';
import { getCurrentValue, LiveType, RelType, setValue } from './LiveUtils';
import { Validation } from './Validation';
const firstOrItem = <T extends {}>(item: T | T[]): T =>
item instanceof Array ? first(item) : item;
interface IProps<E extends ListItemEntity<any>, P extends keyof E> extends IDropdownProps {
entity: E;
propertyName: P;
getKeyFromValue: (val: RelType<E, P>) => (string | number);
renderValue?: (val: LiveType<E, P>) => ReactNode;
transformer?: ITransformer<LiveType<E, P>>;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
autoFocus?: boolean;
tooltip?: string;
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveDropdown = <E extends ListItemEntity<any>, P extends keyof E>(props: IProps<E, P>) => {
const {
entity,
propertyName,
getKeyFromValue,
renderValue: customValueRenderer,
transformer = new NonTransformer(),
rules,
showValidationFeedback,
autoFocus,
label,
ariaLabel = label,
tooltip,
updateField
} = props;
const value = firstOrItem(transformer.transform(getCurrentValue(entity, propertyName)));
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, transformer.reverse(val))), [updateField, propertyName]);
const renderValue = useCallback((val: LiveType<E, P>) => <>{customValueRenderer(val)}</>, [customValueRenderer]);
const onChange = useCallback((ev, { data, key }: IDropdownOption) =>
updateField(e => {
const value = (data || key) as LiveType<E, P>;
setValue(e, propertyName, transformer.reverse(value instanceof Array ? [value] as any : value));
}),
[updateField, propertyName, value]
);
const key = getKeyFromValue(value as RelType<E, P>);
const dropDownRef = useRef<IDropdown>();
useEffect(() => { if (autoFocus) dropDownRef.current?.focus(); }, [autoFocus]);
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} transformer={transformer} updateValue={updateValue} renderValue={renderValue}>
{(renderLiveUpdateMark) =>
<Dropdown
{...props}
componentRef={dropDownRef}
ariaLabel={ariaLabel}
onRenderLabel={(textFieldProps, defaultRender) =>
<Stack horizontal>
<InfoTooltip text={tooltip}>{defaultRender(textFieldProps)}</InfoTooltip>
{renderLiveUpdateMark()}
</Stack>}
multiSelect={false}
selectedKey={key}
onChange={onChange}
/>
}
</LiveUpdate>
</Validation>
);
};
export default LiveDropdown;

View File

@ -0,0 +1,100 @@
import { remove } from 'lodash';
import React, { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { IDropdownProps, Dropdown, Stack, IDropdownOption, IDropdown, useTheme, IDropdownStyles, ICheckStyleProps } from '@fluentui/react';
import { ValidationRule } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import { InfoTooltip } from './InfoTooltip';
import LiveUpdate, { ITransformer, NonTransformer } from './LiveUpdate';
import { getCurrentValue, LiveType, RelType, setValue } from './LiveUtils';
import { Validation } from './Validation';
interface IProps<E extends ListItemEntity<any>, P extends keyof E> extends IDropdownProps {
entity: E;
propertyName: P;
getKeyFromValue: (val: RelType<E, P>) => string | number;
renderValue?: (val: LiveType<E, P>) => ReactNode;
transformer?: ITransformer<LiveType<E, P>>;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
autoFocus?: boolean;
tooltip?: string;
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveMultiselectDropdown = <E extends ListItemEntity<any>, P extends keyof E>(props: IProps<E, P>) => {
const {
entity,
propertyName,
getKeyFromValue,
renderValue: customValueRenderer,
transformer = new NonTransformer(),
rules,
showValidationFeedback,
autoFocus,
label,
ariaLabel = label,
tooltip,
updateField
} = props;
const values = transformer.transform(getCurrentValue(entity, propertyName));
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, transformer.reverse(val))), [updateField, propertyName]);
const renderValue = useCallback((val: LiveType<E, P>) => <>{customValueRenderer(val)}</>, [customValueRenderer]);
const onChange = useCallback((ev, { selected, data, key }: IDropdownOption) =>
updateField(e => {
const value = (data || key) as LiveType<E, P>;
if (selected)
(values as any).push(value);
else
remove(values as any, v => v === value);
setValue(e, propertyName, transformer.reverse(values));
}),
[updateField, propertyName, values]
);
const keys = (values as any).map(getKeyFromValue);
const dropDownRef = useRef<IDropdown>();
useEffect(() => { if (autoFocus) dropDownRef.current?.focus(); }, [autoFocus]);
const { palette: { neutralDark, neutralPrimary } } = useTheme();
const fixHighContrastThemeStyles = useMemo(() => {
return {
subComponentStyles: {
multiSelectItem: ({ checked }: ICheckStyleProps) => {
return {
label: { color: checked ? neutralDark : neutralPrimary }
};
}
}
} as IDropdownStyles;
}, [neutralDark, neutralPrimary]);
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} transformer={transformer} updateValue={updateValue} renderValue={renderValue}>
{(renderLiveUpdateMark) =>
<Dropdown
{...props}
componentRef={dropDownRef}
ariaLabel={ariaLabel}
onRenderLabel={(textFieldProps, defaultRender) =>
<Stack horizontal>
<InfoTooltip text={tooltip}>{defaultRender(textFieldProps)}</InfoTooltip>
{renderLiveUpdateMark()}
</Stack>}
multiSelect
selectedKeys={keys}
onChange={onChange}
styles={fixHighContrastThemeStyles}
/>
}
</LiveUpdate>
</Validation>
);
};
export default LiveMultiselectDropdown;

View File

@ -0,0 +1,147 @@
import { difference, includes } from "lodash";
import React, { ReactNode } from "react";
import { Comparer, Entity } from "common";
import { ListItemEntity } from "common/sharepoint";
import { IOneToManyRelationship, OneToManyRelationship } from "../Entity";
enum Status {
added,
deleted,
movedIn,
movedOut,
reordered,
unchanged
}
type Context<E> = {
entity: E;
index: number;
status: Status;
isAdded: boolean;
isDeleted: boolean;
isMovedIn: boolean;
isMovedOut: boolean;
isReordered: boolean;
isUnchanged: boolean;
};
const parentRelationship = (relationship: IOneToManyRelationship<any>, child: ListItemEntity<any>) => {
return (relationship as OneToManyRelationship<any, any>)._parentRelationship(child);
};
interface IProps<E extends ListItemEntity<any>> {
relationship: IOneToManyRelationship<E>;
excludeRemoved?: boolean;
comparer?: Comparer<E>;
separator?: ReactNode;
children: (context: Context<E>) => ReactNode;
}
const LiveRelationship = <E extends ListItemEntity<any>>({
relationship,
excludeRemoved = false,
comparer,
separator = <></>,
children
}: IProps<E>) => {
const output: [E, Status][] = [];
const current: E[] = relationship.filter(Entity.NotDeletedFilter);
if (comparer) current.sort(comparer);
const snapshot: E[] = (() => {
if (!relationship.hasSnapshot) return current;
const items = relationship.snapshotValue();
items.filter(item => item.hasSnapshot).forEach(item => item.peekSnapshot());
const filteredItems = items.filter(Entity.NotDeletedFilter);
if (comparer) filteredItems.sort(comparer);
items.forEach(item => item.endPeek());
return filteredItems;
})();
const previous: E[] = (() => {
if (!relationship.hasPrevious) return snapshot;
const items = relationship.previousValue();
items.filter(item => item.hasPrevious).forEach(item => item.peekPrevious());
const filteredItems = items.filter(Entity.NotDeletedFilter);
if (comparer) filteredItems.sort(comparer);
items.forEach(item => item.endPeek());
return filteredItems;
})();
const added = difference(current, previous);
const removed = difference(previous, current);
const stableCurrent = difference(current, added);
const stablePrevious = difference(previous, removed);
const length = stableCurrent.length + added.length + removed.length;
let i_current = 0;
let i_previous = 0;
let i_stable = 0;
for (let i = 0; i < length; i++) {
const current_item = current[i_current];
const previous_item = previous[i_previous];
if (current_item && includes(added, current_item)) { // added/moved-in locally or remotely
if (includes(snapshot, current_item)) { // added/moved-in remotely
const parent = parentRelationship(relationship, current_item);
if (parent.hasPrevious && parent.getPrevious()) { // moved-in remotely
output.push([current_item, Status.movedIn]);
} else { // added remotely
output.push([current_item, Status.added]);
}
} else { // added/moved-in locally
output.push([current_item, Status.unchanged]);
}
i_current++;
} else if (previous_item && includes(removed, previous_item)) { // deleted/moved-out locally or remotely
if (!excludeRemoved) {
if (previous_item.isDeleted) { // deleted locally/remotely
if (!previous_item.hasSnapshot || previous_item.snapshotValue<boolean>('isDeleted') === true) { // deleted remotely
output.push([previous_item, Status.deleted]);
} else { // deleted locally
// do not output
}
} else { // moved-out locally or remotely
if (includes(snapshot, previous_item)) { // moved-out locally
// do not output
} else { // moved-out remotely
output.push([previous_item, Status.movedOut]);
}
}
}
i_previous++;
} else {
if (stableCurrent[i_stable] !== stablePrevious[i_stable]) {
output.push([current_item, Status.reordered]);
} else {
output.push([current_item, Status.unchanged]);
}
i_stable++;
i_current++;
i_previous++;
}
}
return <>{output.map(([entity, status], idx) => <>
{idx > 0 && separator}
{children({
entity,
index: current.indexOf(entity),
status,
isAdded: status === Status.added,
isDeleted: status === Status.deleted,
isMovedIn: status === Status.movedIn,
isMovedOut: status === Status.movedOut,
isReordered: status === Status.reordered,
isUnchanged: status === Status.unchanged
})}
</>)}</>;
};
export default LiveRelationship;

View File

@ -0,0 +1,51 @@
import React, { ReactNode } from 'react';
import { ITextFieldProps, Stack, Label, Text, ILabelStyles } from '@fluentui/react';
import { ListItemEntity } from 'common/sharepoint';
import { InfoTooltip } from './InfoTooltip';
import LiveUpdate, { ITransformer, NonTransformer, StateType } from './LiveUpdate';
import { getCurrentValue, LiveType } from './LiveUtils';
const labelStyles: ILabelStyles = {
root: { display: 'inline-block' }
};
interface IProps<E extends ListItemEntity<any>, P extends keyof E> extends ITextFieldProps {
entity: E;
propertyName: P;
label?: string;
labelAlign?: 'normal' | 'centered';
tooltip?: string;
transformer?: ITransformer<LiveType<E, P>>;
children?: (val: LiveType<E, P>, state: StateType) => ReactNode;
}
const LiveText = <E extends ListItemEntity<any>, P extends keyof E>(props: IProps<E, P>) => {
const {
entity,
propertyName,
label,
labelAlign = 'normal',
tooltip,
transformer = new NonTransformer(),
children = val => <Text tabIndex={0}>{val || '-'}</Text>
} = props;
const value = transformer.transform(getCurrentValue(entity, propertyName));
return (
<LiveUpdate entity={entity} propertyName={propertyName} renderValue={children} transformer={transformer}>
{(renderLiveUpdateMark) => <>
{label &&
<Stack horizontal horizontalAlign={labelAlign === 'centered' ? 'space-around' : 'start'}>
<InfoTooltip text={tooltip}><Label styles={labelStyles}>{label}</Label></InfoTooltip>
{renderLiveUpdateMark()}
</Stack>
}
{children(value, 'current')}
{!label && renderLiveUpdateMark()}
</>}
</LiveUpdate>
);
};
export default LiveText;

View File

@ -0,0 +1,76 @@
import React, { useCallback } from 'react';
import { ITextFieldProps, TextField, Stack } from '@fluentui/react';
import { ValidationRule, PropsOfType } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import { InfoTooltip } from './InfoTooltip';
import LiveUpdate from './LiveUpdate';
import { getCurrentValue, LiveType, setValue } from './LiveUtils';
import { Validation } from './Validation';
type DataType = string | number;
interface IConverter<T> {
parse: (val: string) => T;
toString: (val: T) => string;
}
class NonConverter implements IConverter<string> {
public parse(val: string) { return val; }
public toString(val: string) { return val; }
}
interface IProps<E extends ListItemEntity<any>, P extends PropsOfType<E, DataType>> extends ITextFieldProps {
entity: E;
propertyName: P;
converter?: IConverter<LiveType<E, P>>;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
liveUpdateMarkClassName?: string;
tooltip?: string;
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveTextField = <E extends ListItemEntity<any>, P extends PropsOfType<E, DataType>>(props: IProps<E, P>) => {
const {
entity,
propertyName,
converter = new NonConverter() as unknown as IConverter<LiveType<E, P>>,
rules,
showValidationFeedback,
label,
ariaLabel = label,
liveUpdateMarkClassName,
tooltip,
updateField
} = props;
const value = converter.toString(getCurrentValue(entity, propertyName));
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, val)), [updateField, propertyName]);
const renderValue = useCallback((val: LiveType<E, P>) => <>{(converter ? converter.toString(val) : val) || '-'}</>, [converter]);
const onChange = useCallback((ev, val) => updateField(e => setValue(e, propertyName, converter ? converter.parse(val) : val as unknown as LiveType<E, P>)), [updateField, propertyName, converter]);
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} updateValue={updateValue} renderValue={renderValue}>
{(renderLiveUpdateMark) => <>
<TextField
title={''}
{...props}
ariaLabel={ariaLabel}
onRenderLabel={(textFieldProps, defaultRender) => {
return label && <Stack horizontal>
<InfoTooltip text={tooltip}>{defaultRender(textFieldProps)}</InfoTooltip>
{renderLiveUpdateMark({ className: liveUpdateMarkClassName })}
</Stack>;
}}
value={value}
onChange={onChange}
/>
{!label && renderLiveUpdateMark({ className: liveUpdateMarkClassName })}
</>}
</LiveUpdate>
</Validation>
);
};
export default LiveTextField;

View File

@ -0,0 +1,63 @@
import { Duration } from 'moment-timezone';
import React, { useCallback } from 'react';
import { Label, Stack } from '@fluentui/react';
import { ValidationRule, PropsOfType, now } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import { InfoTooltip } from './InfoTooltip';
import LiveUpdate from './LiveUpdate';
import { ITimePickerProps, TimePicker } from './TimePicker';
import { Validation } from './Validation';
import { getCurrentValue, LiveType, setValue } from './LiveUtils';
type DataType = Duration;
interface IProps<E extends ListItemEntity<any>, P extends PropsOfType<E, T>, T extends DataType> extends Omit<ITimePickerProps, 'value' | 'onChange'> {
entity: E;
propertyName: P;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
tooltip?: string;
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveTimePicker = <E extends ListItemEntity<any>, P extends PropsOfType<E, T>, T extends DataType>(props: IProps<E, P, T>) => {
const {
entity,
propertyName,
rules,
showValidationFeedback,
label,
ariaLabel = label,
tooltip,
required,
updateField
} = props;
const value = getCurrentValue(entity, propertyName) as T;
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, val)), [updateField, propertyName]);
const renderValue = useCallback((val: LiveType<E, P>) => <span>{now().startOf('day').add(val as Duration).format('LT')}</span>, []);
const onChange = useCallback((value: Duration) => updateField(e => setValue(e, propertyName, value as LiveType<E, P>)), [updateField, propertyName]);
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} updateValue={updateValue} renderValue={renderValue}>
{(renderLiveUpdateMark) => <>
{label && <Stack horizontal>
<InfoTooltip text={tooltip}><Label required={required}>{label}</Label></InfoTooltip>
{renderLiveUpdateMark()}
</Stack>}
<TimePicker
{...props}
label={undefined}
ariaLabel={ariaLabel}
value={value}
onChange={onChange}
/>
{!label && renderLiveUpdateMark()}
</>}
</LiveUpdate>
</Validation>
);
};
export default LiveTimePicker;

View File

@ -0,0 +1,75 @@
import React, { useCallback } from 'react';
import { Toggle, IToggleProps, IToggleStyles, IButtonStyles } from '@fluentui/react';
import { ValidationRule, PropsOfType } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import { InfoTooltip } from './InfoTooltip';
import LiveUpdate from './LiveUpdate';
import { getCurrentValue, LiveType, setValue } from './LiveUtils';
import { Validation } from './Validation';
import * as strings from "CommonStrings";
import { useConst } from '@fluentui/react-hooks';
type DataType = boolean;
const toggleStyles: Partial<IToggleStyles> = {
label: { paddingBottom: 10 }
};
interface IProps<E extends ListItemEntity<any>, P extends PropsOfType<E, DataType>> extends IToggleProps {
entity: E;
propertyName: P;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
tooltip?: string;
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveToggle = <E extends ListItemEntity<any>, P extends PropsOfType<E, DataType>>(props: IProps<E, P>) => {
const {
entity,
propertyName,
rules,
showValidationFeedback,
label,
ariaLabel = typeof label === 'string' ? label : undefined,
tooltip,
onText,
offText,
updateField
} = props;
const value = getCurrentValue(entity, propertyName) as DataType;
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, val)), [updateField, propertyName]);
const renderValue = useCallback((val: LiveType<E, P>) => <>{val ? (onText || strings.LiveUpdate.Toggle.OnText) : (offText || strings.LiveUpdate.Toggle.OffText)}</>, [onText, offText]);
const onChange = useCallback((ev, val: DataType) => updateField(e => setValue(e, propertyName, val as LiveType<E, P>)), [updateField, propertyName]);
const liveUpdateMarkStylesNoLabel: IButtonStyles = useConst({
root: { position: 'absolute', right: -5, top: 5 }
});
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} updateValue={updateValue} renderValue={renderValue}>
{(renderLiveUpdateMark) => <>
<InfoTooltip text={!label ? tooltip : undefined} hideIcon>
<Toggle
{...props}
ariaLabel={ariaLabel}
label={label && <>
<InfoTooltip text={tooltip}>{label}</InfoTooltip>
{renderLiveUpdateMark()}
</>}
styles={toggleStyles}
checked={value}
onChange={onChange}
/>
{!label && renderLiveUpdateMark({ styles: liveUpdateMarkStylesNoLabel })}
</InfoTooltip>
</>}
</LiveUpdate>
</Validation>
);
};
export default LiveToggle;

View File

@ -0,0 +1,287 @@
import { isEqualWith, noop } from 'lodash';
import { duration, Moment } from 'moment-timezone';
import React, { FC, ReactNode, createRef, useState, memo, useMemo, CSSProperties } from 'react';
import { format, IconButton, IButtonProps, Stack, ActionButton, Text, Persona, PersonaSize, IStackTokens, useTheme, IButtonStyles, concatStyleSets, TooltipHost, ICalloutContentStyles, IFocusTrapZoneProps, FocusTrapCallout, FocusZone } from '@fluentui/react';
import { useConst } from '@fluentui/react-hooks';
import { LocationFillIcon } from '@fluentui/react-icons-mdl2';
import { now, PropsOfType, stateIsEqualCustomizer, stopPropagation, User } from 'common';
import { useDirectoryService } from 'common/services';
import { ListItemEntity } from 'common/sharepoint';
import { getCurrentValue, getHasPreviousValue, getHasSnapshotValue, getPreviousValue, getSnapshotValue, LiveType } from './LiveUtils';
import { LiveUpdate as strings } from "CommonStrings";
import styles from './styles/LiveComponents.module.scss';
export type StateType = 'current' | 'previous' | 'snapshot';
const DefaultRecentlyModifiedWindow = duration(15, 'minutes');
const humanizeDurationFromNow = (time: Moment) => duration(time.diff(now())).humanize(true);
const editorStackTokens: IStackTokens = { childrenGap: 8 };
const calloutStackTokens: IStackTokens = { childrenGap: 24 };
const valueComparisonStackTokens: IStackTokens = { childrenGap: 8, padding: '0 0 0 26px' };
const calloutStyles: Partial<ICalloutContentStyles> = { calloutMain: { padding: 1 } };
const EditorDetails: FC<{ label: string, editor: User, modified: Moment }> = memo(({ label, editor, modified }) => {
const { title, email, picture } = editor;
return (
<Stack tokens={editorStackTokens}>
<Text block variant="small" data-is-focusable>{label}, {humanizeDurationFromNow(modified)}</Text>
<Persona imageUrl={picture} text={title} secondaryText={email} showSecondaryText size={PersonaSize.size32} data-is-focusable />
</Stack>
);
});
export type ILiveUpdateMarkProps = IButtonProps;
export interface ITransformer<T> {
transform: (val: T) => T;
reverse: (val: T) => T;
}
export class NonTransformer<T> implements ITransformer<T> {
public transform(val: T) { return val; }
public reverse(val: T) { return val; }
}
export interface ILiveUpdateStyles {
entity: IButtonStyles;
field: IButtonStyles;
}
interface IProps<E extends ListItemEntity<any>, P extends PropsOfType<E, any>> {
entity: E;
propertyName?: P;
onlyShowEntityLifetimeEvents?: boolean;
transformer?: ITransformer<LiveType<E, P>>;
liveUpdateMarkStyles?: Partial<ILiveUpdateStyles>;
compact?: boolean;
updateValue?: (val: LiveType<E, P>) => void;
renderValue?: (val: LiveType<E, P>, state: StateType) => ReactNode;
children: (renderLiveUpdateMark: (props?: ILiveUpdateMarkProps) => ReactNode) => ReactNode;
}
const LiveUpdate = <E extends ListItemEntity<any>, P extends PropsOfType<E, any>>({
entity,
propertyName,
onlyShowEntityLifetimeEvents = false,
transformer = new NonTransformer(),
liveUpdateMarkStyles,
compact = false,
updateValue = noop,
renderValue = val => <>{val}</>,
children
}: IProps<E, P>) => {
const { palette: { themePrimary } } = useTheme();
const { currentUser } = useDirectoryService();
const PeopleIconButtonRef = createRef<HTMLElement>();
const [isCalloutOpen, setCalloutOpen] = useState(false);
const toggleCallout = stopPropagation(() => setCalloutOpen(!isCalloutOpen));
const closeCallout = () => setCalloutOpen(false);
const hasPropertyName = !!propertyName;
const { editor, created, modified, isNew, isDeleted } = entity;
const isMyOwnChange = isNew || (editor && User.equal(editor, currentUser));
const isRecentlyModified = modified.isSameOrAfter(now().subtract(DefaultRecentlyModifiedWindow));
const hasSnapshot = getHasSnapshotValue(entity, propertyName);
const hasPrevious = getHasPreviousValue(entity, propertyName) && (isCalloutOpen || hasSnapshot || isRecentlyModified);
const isNewItem = isRecentlyModified && modified.isSame(created);
const currentValue = transformer.transform(getCurrentValue(entity, propertyName));
const previousValue = (hasPrevious && hasPropertyName) ? transformer.transform(getPreviousValue(entity, propertyName)) : undefined;
const snapshotValue = (hasSnapshot && hasPropertyName) ? transformer.transform(getSnapshotValue(entity, propertyName)) : undefined;
const previousValueChanged = hasPrevious && (!hasPropertyName || !isEqualWith(previousValue, hasSnapshot ? snapshotValue : currentValue, stateIsEqualCustomizer));
const snapshotValueChanged = (hasSnapshot && hasPropertyName && !isEqualWith(currentValue, snapshotValue, stateIsEqualCustomizer));
const defaultStyles: ILiveUpdateStyles = useMemo(() => {
return {
entity: {
root: { color: themePrimary, height: 25 },
label: { fontSize: 12, margin: 0 }
},
field: {
root: { position: 'relative', top: 2, right: -2, height: 22, width: 22 },
icon: { fontSize: 12 }
}
};
}, [themePrimary]);
const currentChangeDotStyle: CSSProperties = useMemo(() => {
return { color: themePrimary, position: 'absolute', left: 0, top: 2 };
}, [themePrimary]);
const renderLiveUpdateMark = (props: ILiveUpdateMarkProps = {}) => {
const entityLiveUpdateMarkStyles = concatStyleSets(defaultStyles.entity, liveUpdateMarkStyles?.entity, props.styles);
const fieldLiveUpdateMarkStyles = concatStyleSets(defaultStyles.field, liveUpdateMarkStyles?.field, props.styles);
if (!isMyOwnChange) {
if (isNewItem && !hasPropertyName) {
return (
<TooltipHost content={strings.RecentlyAddedMarkTooltip}>
<ActionButton
{...props}
elementRef={PeopleIconButtonRef}
iconProps={{ iconName: "LocationFill" }}
onClick={toggleCallout}
styles={entityLiveUpdateMarkStyles}
text={compact ? undefined : strings.New}
ariaLabel={strings.RecentlyAddedMarkTooltip}
/>
</TooltipHost>
);
} else if (isDeleted && !hasPropertyName) {
return (
<TooltipHost content={strings.RecentlyDeletedMarkTooltip}>
<ActionButton
{...props}
elementRef={PeopleIconButtonRef}
iconProps={{ iconName: "LocationFill" }}
title={strings.RecentlyDeletedMarkTooltip}
onClick={toggleCallout}
styles={entityLiveUpdateMarkStyles}
text={compact ? undefined : strings.Deleted}
ariaLabel={strings.RecentlyDeletedMarkTooltip}
/>
</TooltipHost>
);
} else if (previousValueChanged && !isDeleted && !onlyShowEntityLifetimeEvents) {
if (!hasPropertyName) {
return (
<TooltipHost content={strings.RecentlyEditedMarkTooltip}>
<ActionButton
{...props}
elementRef={PeopleIconButtonRef}
iconProps={{ iconName: "LocationFill" }}
onClick={toggleCallout}
styles={entityLiveUpdateMarkStyles}
text={compact ? undefined : strings.Updated}
ariaLabel={strings.RecentlyEditedMarkTooltip}
/>
</TooltipHost>
);
} else {
return (
<TooltipHost content={strings.RecentlyEditedMarkTooltip}>
<IconButton
{...props}
elementRef={PeopleIconButtonRef}
iconProps={{ iconName: "FieldChanged" }}
styles={fieldLiveUpdateMarkStyles}
ariaLabel={strings.RecentlyEditedMarkTooltip}
onClick={toggleCallout}
/>
</TooltipHost>
);
}
}
}
return <></>;
};
const onRevertToOriginal = () => { updateValue(transformer.reverse(previousValue)); closeCallout(); };
const onUndelete = () => { entity.undelete(); updateValue(transformer.reverse(currentValue)); closeCallout(); };
const onTakeTheirs = () => { updateValue(transformer.reverse(snapshotValue)); closeCallout(); };
const onKeepCurrent = closeCallout;
const { ItemWasAdded, ItemWasDeleted, ItemWasEdited } = strings.Callout;
const focusTrapProps = useConst<IFocusTrapZoneProps>({
isClickableOutsideFocusTrap: true,
forceFocusInsideTrap: false,
});
return <>
{children(renderLiveUpdateMark)}
{(isNewItem || previousValueChanged) && isCalloutOpen &&
<FocusTrapCallout
className={styles.callout}
styles={calloutStyles}
gapSpace={0}
target={PeopleIconButtonRef}
onDismiss={closeCallout}
setInitialFocus
focusTrapProps={focusTrapProps}
>
<FocusZone isCircularNavigation>
{!hasPropertyName &&
<Stack tokens={valueComparisonStackTokens}>
<Stack horizontal verticalAlign="center" tokens={editorStackTokens}>
<Persona imageUrl={editor?.picture} hidePersonaDetails size={PersonaSize.size24} />
<Text block variant="small" data-is-focusable>
{format(
isNewItem ? ItemWasAdded : (isDeleted ? ItemWasDeleted : ItemWasEdited),
editor?.title,
humanizeDurationFromNow(modified)
)}
</Text>
</Stack>
{isDeleted && hasSnapshot &&
<ActionButton iconProps={{ iconName: "History" }} onClick={onUndelete}>
{strings.Callout.UndeleteButton.Text}
</ActionButton>
}
</Stack>
}
{hasPropertyName &&
<Stack tokens={calloutStackTokens}>
{snapshotValueChanged &&
<Stack tokens={valueComparisonStackTokens}>
<Text block variant="large" data-is-focusable>
<Text style={currentChangeDotStyle}><LocationFillIcon /></Text>
{renderValue(currentValue, 'current')}
</Text>
<Text block variant="small" data-is-focusable>{strings.Callout.MyChangeLabel}</Text>
<ActionButton
iconProps={{ iconName: "CheckMark" }}
onClick={onKeepCurrent}
text={strings.Callout.KeepMineButton.Text}
/>
</Stack>
}
<Stack tokens={valueComparisonStackTokens}>
<Text block variant="large" data-is-focusable>
{!snapshotValueChanged && <Text style={currentChangeDotStyle}><LocationFillIcon /></Text>}
{renderValue(hasSnapshot ? snapshotValue : currentValue, hasSnapshot ? 'snapshot' : 'current')}
</Text>
<EditorDetails
label={strings.Callout.TheirChangeLabel}
editor={hasSnapshot ? entity.snapshotValue("editor") : editor}
modified={hasSnapshot ? entity.snapshotValue('modified') : modified}
/>
{hasSnapshot && (snapshotValueChanged
? <ActionButton iconProps={{ iconName: "ReminderGroup" }} onClick={onTakeTheirs}>
{strings.Callout.TakeTheirsButton.Text}
</ActionButton>
: <ActionButton iconProps={{ iconName: "CheckMark" }} onClick={onKeepCurrent}>
{strings.Callout.KeepTheirsButton.Text}
</ActionButton>
)}
</Stack>
<Stack tokens={valueComparisonStackTokens}>
<Text block variant="large" data-is-focusable>
{renderValue(previousValue, 'previous')}
</Text>
<EditorDetails
label={strings.Callout.OriginalLabel}
editor={entity.previousValue("editor")}
modified={entity.previousValue('modified')}
/>
{hasSnapshot &&
<ActionButton iconProps={{ iconName: "History" }} onClick={onRevertToOriginal}>
{strings.Callout.RevertToOriginalButton.Text}
</ActionButton>
}
</Stack>
</Stack>
}
</FocusZone>
</FocusTrapCallout>
}
</>;
};
export default LiveUpdate;

View File

@ -0,0 +1,72 @@
import { last } from 'lodash';
import React, { useCallback } from 'react';
import { ILabelStyles, Label, Stack } from '@fluentui/react';
import { PropsOfType, ValidationRule, User } from 'common';
import { ListItemEntity } from 'common/sharepoint';
import { InfoTooltip } from './InfoTooltip';
import LiveUpdate from './LiveUpdate';
import { getCurrentValue, LiveType, setValue } from './LiveUtils';
import { Validation } from './Validation';
import UserPicker, { IUserPickerProps } from './UserPicker';
const labelStyles: ILabelStyles = {
root: { display: 'inline-block' }
};
interface IProps<E extends ListItemEntity<any>, P extends PropsOfType<E, User> | PropsOfType<E, User[]>> extends Omit<IUserPickerProps, 'users' | 'onChanged'> {
entity: E;
propertyName: P;
rules?: ValidationRule<E>[];
showValidationFeedback?: boolean;
label?: string;
tooltip?: string;
required?: boolean;
onUsersChanging?: (users: User[]) => User[];
updateField: (update: (data: E) => void, callback?: () => any) => void;
}
const LiveUserPicker = <E extends ListItemEntity<any>, P extends PropsOfType<E, User> | PropsOfType<E, User[]>>(props: IProps<E, P>) => {
const {
entity,
propertyName,
rules,
showValidationFeedback,
label,
tooltip,
required,
onUsersChanging = users => users,
updateField
} = props;
const value = getCurrentValue(entity, propertyName) as (User | User[]);
const updateValue = useCallback((val: LiveType<E, P>) => updateField(e => setValue(e, propertyName, val)), [updateField, propertyName]);
const renderValue = useCallback((val: LiveType<E, P>) => Array.isArray(val) ? (val as User[]).map((v, idx) => <span key={idx}>{idx > 0 ? '; ' : ''}{v.title}</span>) : (val as User)?.title || '', []);
const onChanged = useCallback((users: User[]) => {
users = onUsersChanging(users);
updateField(e => setValue(e, propertyName, (Array.isArray(value) ? users : last(users)) as LiveType<E, P>));
}, [onUsersChanging, updateField, propertyName, value]);
return (
<Validation entity={entity} rules={rules} active={showValidationFeedback}>
<LiveUpdate entity={entity} propertyName={propertyName} updateValue={updateValue} renderValue={renderValue}>
{(renderLiveUpdateMark) => <>
{label && <Stack horizontal>
<InfoTooltip text={tooltip}><Label required={required} styles={labelStyles}>{label}</Label></InfoTooltip>
{renderLiveUpdateMark()}
</Stack>}
<UserPicker
{...props}
label={undefined}
ariaLabel={label}
required={required}
users={Array.isArray(value) ? value : [value]}
onChanged={onChanged}
/>
{!label && renderLiveUpdateMark()}
</>}
</LiveUpdate>
</Validation>
);
};
export default LiveUserPicker;

View File

@ -0,0 +1,103 @@
import { IManyToManyRelationship, IManyToOneRelationship, IOneToManyRelationship, ManyToManyRelationship, ManyToOneRelationship, OneToManyRelationship } from "common"
import { ListItemEntity } from "common/sharepoint";
const isOneToManyRelationship = <T>(obj: any): obj is IOneToManyRelationship<T> =>
obj instanceof OneToManyRelationship
const isManyToManyRelationship = <T>(obj: any): obj is IManyToManyRelationship<T> =>
obj instanceof ManyToManyRelationship
const isManyToOneRelationship = <T>(obj: any): obj is IManyToOneRelationship<T> =>
obj instanceof ManyToOneRelationship
export type LiveType<E extends ListItemEntity<any>, P extends keyof E> =
E[P] extends IManyToManyRelationship<infer T>
? T[]
: (E[P] extends IManyToOneRelationship<infer T>
? T
: (E[P] extends IOneToManyRelationship<any>
? never
: E[P]
)
);
export type RelType<E extends ListItemEntity<any>, P extends keyof E> =
E[P] extends (IOneToManyRelationship<infer T> | IManyToManyRelationship<infer T> | IManyToOneRelationship<infer T>) ? T : (E[P] extends Array<infer T> ? T : E[P]);
export const getCurrentValue = <E extends ListItemEntity<any>, P extends keyof E>(entity: E, propertyName: P): LiveType<E, P> => {
const raw: E[P] = entity[propertyName];
if (isManyToOneRelationship<RelType<E, P>>(raw)) {
return raw.get() as LiveType<E, P>;
} else if (isManyToManyRelationship<RelType<E, P>>(raw)) {
return raw.get() as LiveType<E, P>;
} else if (isOneToManyRelationship<RelType<E, P>>(raw)) {
throw new Error('One-to-many relationships are not supported by the LiveUpdate component. Use LiveRelationship instead');
} else {
return raw as LiveType<E, P>;
}
};
export const getHasSnapshotValue = <E extends ListItemEntity<any>, P extends keyof E>(entity: E, propertyName: P): boolean => {
const raw: E[P] = entity[propertyName];
if (isManyToOneRelationship<RelType<E, P>>(raw)) {
return raw.hasSnapshot;
} else if (isManyToManyRelationship<RelType<E, P>>(raw)) {
return raw.hasSnapshot;
} else if (isOneToManyRelationship<RelType<E, P>>(raw)) {
throw new Error('One-to-many relationships are not supported by the LiveUpdate component. Use LiveRelationship instead');
} else {
return entity.hasSnapshot;
}
};
export const getSnapshotValue = <E extends ListItemEntity<any>, P extends keyof E>(entity: E, propertyName: P): LiveType<E, P> => {
const raw: E[P] = entity[propertyName];
if (isManyToOneRelationship<RelType<E, P>>(raw)) {
return raw.getSnapshot() as LiveType<E, P>;
} else if (isManyToManyRelationship<RelType<E, P>>(raw)) {
return raw.snapshotValue() as LiveType<E, P>;
} else if (isOneToManyRelationship<RelType<E, P>>(raw)) {
throw new Error('One-to-many relationships are not supported by the LiveUpdate component. Use LiveRelationship instead');
} else {
return entity.snapshotValue(propertyName) as LiveType<E, P>;
}
};
export const getHasPreviousValue = <E extends ListItemEntity<any>, P extends keyof E>(entity: E, propertyName: P): boolean => {
const raw: E[P] = entity[propertyName];
if (isManyToOneRelationship<RelType<E, P>>(raw)) {
return raw.hasPrevious;
} else if (isManyToManyRelationship<RelType<E, P>>(raw)) {
return raw.hasPrevious;
} else if (isOneToManyRelationship<RelType<E, P>>(raw)) {
throw new Error('One-to-many relationships are not supported by the LiveUpdate component. Use LiveRelationship instead');
} else {
return entity.hasPrevious;
}
};
export const getPreviousValue = <E extends ListItemEntity<any>, P extends keyof E>(entity: E, propertyName: P): LiveType<E, P> => {
const raw: E[P] = entity[propertyName];
if (isManyToOneRelationship<RelType<E, P>>(raw)) {
return raw.getPrevious() as LiveType<E, P>;
} else if (isManyToManyRelationship<RelType<E, P>>(raw)) {
return raw.previousValue() as LiveType<E, P>;
} else if (isOneToManyRelationship<RelType<E, P>>(raw)) {
throw new Error('One-to-many relationships are not supported by the LiveUpdate component. Use LiveRelationship instead');
} else {
return entity.previousValue(propertyName) as LiveType<E, P>;
}
};
export const setValue = <E extends ListItemEntity<any>, P extends keyof E>(entity: E, propertyName: P, val: LiveType<E, P>) => {
const raw: E[P] = entity[propertyName];
if (isManyToOneRelationship<RelType<E, P>>(raw)) {
raw.set(val as RelType<E, P>);
} else if (isManyToManyRelationship<RelType<E, P>>(raw)) {
raw.set(val as RelType<E, P>[]);
} else if (isOneToManyRelationship<RelType<E, P>>(raw)) {
throw new Error('One-to-many relationships are not supported by the LiveUpdate component. Use LiveRelationship instead');
} else {
entity[propertyName] = val as E[P];
}
};

View File

@ -0,0 +1,22 @@
import React, { FC, ReactElement } from "react";
import { Text } from '@fluentui/react';
interface IProps {
phrase: string;
skipFirstTextPart?: boolean;
components: { [token: string]: ReactElement };
}
export const Localize: FC<IProps> = ({ phrase, skipFirstTextPart, components }) => {
const matches = [...phrase.matchAll(/{(?<token>.+?)}|(?<text>[^{]+)/g)];
return <>{matches.map(({ groups: { text: textPart, token: componentPart } }, idx) => {
textPart = textPart?.trim();
if (textPart && (!skipFirstTextPart || idx > 0))
return <Text>{textPart}</Text>;
else if (componentPart)
return components[componentPart];
})}</>;
};
export const firstTextPart = (phrase: string) =>
phrase.match(/{.+?}|(?<text>[^{]+)/).groups.text

View File

@ -0,0 +1,124 @@
import React, { FC, ReactNode } from 'react';
import { css } from '@fluentui/react';
interface IGridProps {
className?: string;
children?: ReactNode;
}
export const ResponsiveGrid: FC<IGridProps> = ({ className, children }) =>
<div className={css('ms-Grid', className)}>
{children}
</div>;
interface IRowProps {
className?: string;
children?: ReactNode;
}
export const GridRow: FC<IRowProps> = ({ className, children }) =>
<div className={css('ms-Grid-row', className)}>
{children}
</div>;
type ColumnCount = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type PushPullCount = Exclude<ColumnCount, 12>;
interface IColumnProps {
className?: string;
children?: ReactNode;
sm?: ColumnCount;
md?: ColumnCount;
lg?: ColumnCount;
xl?: ColumnCount;
xxl?: ColumnCount;
xxxl?: ColumnCount;
smPush?: PushPullCount;
mdPush?: PushPullCount;
lgPush?: PushPullCount;
xlPush?: PushPullCount;
xxlPush?: PushPullCount;
xxxlPush?: PushPullCount;
smPull?: PushPullCount;
mdPull?: PushPullCount;
lgPull?: PushPullCount;
xlPull?: PushPullCount;
xxlPull?: PushPullCount;
xxxlPull?: PushPullCount;
hiddenSm?: boolean;
hiddenMd?: boolean;
hiddenMdDown?: boolean;
hiddenMdUp?: boolean;
hiddenLg?: boolean;
hiddenLgDown?: boolean;
hiddenLgUp?: boolean;
hiddenXl?: boolean;
hiddenXlDown?: boolean;
hiddenXlUp?: boolean;
hiddenXxl?: boolean;
hiddenXxlDown?: boolean;
hiddenXxlUp?: boolean;
hiddenXxxl?: boolean;
}
const buildGridColClassName = ({
className,
sm = 12, md, lg, xl, xxl, xxxl,
smPush, mdPush, lgPush, xlPush, xxlPush, xxxlPush,
smPull, mdPull, lgPull, xlPull, xxlPull, xxxlPull,
hiddenSm, hiddenMd, hiddenMdDown, hiddenMdUp, hiddenLg, hiddenLgDown, hiddenLgUp, hiddenXl, hiddenXlDown, hiddenXlUp, hiddenXxl, hiddenXxlDown, hiddenXxlUp, hiddenXxxl
}: IColumnProps) => {
return css(
'ms-Grid-col',
{
['ms-sm' + sm]: true, // always specify column count on small and up screens
['ms-md' + md]: !!md, // only specify column count for medium and up screens if md value is provided
['ms-lg' + lg]: !!lg,
['ms-xl' + xl]: !!xl,
['ms-xxl' + xxl]: !!xxl,
['ms-xxxl' + xxxl]: !!xxxl
},
{
['ms-smPush' + smPush]: !!smPush,
['ms-mdPush' + mdPush]: !!mdPush,
['ms-lgPush' + lgPush]: !!lgPush,
['ms-xlPush' + xlPush]: !!xlPush,
['ms-xxlPush' + xxlPush]: !!xxlPush,
['ms-xxxlPush' + xxxlPush]: !!xxxlPush
},
{
['ms-smPull' + smPull]: !!smPull,
['ms-mdPull' + mdPull]: !!mdPull,
['ms-lgPull' + lgPull]: !!lgPull,
['ms-xlPull' + xlPull]: !!xlPull,
['ms-xxlPull' + xxlPull]: !!xxlPull,
['ms-xxxlPull' + xxxlPull]: !!xxxlPull
},
{
'ms-hiddenSm': hiddenSm,
'ms-hiddenMd': hiddenMd,
'ms-hiddenMdDown': hiddenMdDown,
'ms-hiddenMdUp': hiddenMdUp,
'ms-hiddenLLg': hiddenLg,
'ms-hiddenLLgDown': hiddenLgDown,
'ms-hiddenLLgUp': hiddenLgUp,
'ms-hiddenLXl': hiddenXl,
'ms-hiddenLXlDown': hiddenXlDown,
'ms-hiddenLXlUp': hiddenXlUp,
'ms-hiddenLXxl': hiddenXxl,
'ms-hiddenLXxlDown': hiddenXxlDown,
'ms-hiddenLXxlUp': hiddenXxlUp,
'ms-hiddenLXxxl': hiddenXxxl
},
className
);
};
export const GridCol: FC<IColumnProps> = ({ children, ...classNameParameters }) =>
<div className={buildGridColClassName(classNameParameters)}>
{children}
</div>;

View File

@ -0,0 +1,130 @@
import { noop, isEqual } from 'lodash';
import React, { ReactElement, ReactNode, Component, CSSProperties } from "react";
import { initializeIcons, Shimmer, ThemeProvider as FluentThemeProvider } from "@fluentui/react";
import { ISPFXContext } from '@pnp/common';
import { graph } from "@pnp/graph";
import { sp } from "@pnp/sp";
import { ThemeProvider as SPThemeProvider, ThemeChangedEventArgs, IReadonlyTheme, BaseComponent } from '@microsoft/sp-component-base';
import { IMicrosoftTeams } from '@microsoft/sp-webpart-base';
import { perf } from 'common';
import { ServiceManager, ServicesType, ServicesProvider, ServiceDescriptorArray, SpfxContext } from "common/services";
require('office-ui-fabric-react/dist/css/fabric.min.css');
initializeIcons();
const fluentRootStyle: CSSProperties = { height: '100%' };
interface IProps<D extends ServiceDescriptorArray> {
appName: string;
companyName: string;
spfxComponent: BaseComponent;
spfxContext: SpfxContext;
teams: IMicrosoftTeams;
serviceDescriptors: D;
shimmerElements?: ReactNode;
onInitBeforeServices?: () => Promise<any>;
onInitAfterServices?: (services: ServicesType<D>) => Promise<any>;
children: (services: ServicesType<D>) => ReactElement;
}
interface IState<D extends ServiceDescriptorArray> {
serviceManager: ServiceManager<D>;
}
export class SharePointApp<D extends ServiceDescriptorArray> extends Component<IProps<D>, IState<D>> {
private _themeProvider: SPThemeProvider;
private _theme: IReadonlyTheme;
constructor(props: IProps<D>) {
super(props);
this.state = {
serviceManager: null
};
}
public async componentDidMount() {
const { spfxComponent, spfxContext, onInitBeforeServices = noop, onInitAfterServices } = this.props;
try {
this._configurePnP();
const [
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_unused,
serviceManager
] = await Promise.all([
onInitBeforeServices(),
this._createServiceManager()
]);
this._themeProvider = spfxContext.serviceScope.consume(SPThemeProvider.serviceKey);
this._themeProvider.themeChangedEvent.add(spfxComponent, this._onThemeChanged);
this._theme = this._themeProvider.tryGetTheme();
if (onInitAfterServices) {
await perf('SharePointApp.onInitAfterServices', () => onInitAfterServices(serviceManager.services));
}
this.setState({ serviceManager });
} catch (e) {
console.error(e);
}
}
public componentWillUnmount() {
const { spfxComponent } = this.props;
if (this._themeProvider)
this._themeProvider.themeChangedEvent.remove(spfxComponent, this._onThemeChanged);
}
private readonly _onThemeChanged = ({ theme }: ThemeChangedEventArgs) => {
if (!isEqual(this._theme, theme)) {
this._theme = theme;
this.forceUpdate();
}
}
private _configurePnP() {
const { appName, companyName, spfxContext } = this.props;
const { version } = spfxContext.manifest;
const xClientTag = `${companyName}|${appName}/${version}`;
sp.setup({
spfxContext: spfxContext as unknown as ISPFXContext,
sp: {
headers: {
"X-ClientTag": xClientTag,
"User-Agent": xClientTag
}
}
});
graph.setup(spfxContext as unknown as ISPFXContext);
}
private readonly _createServiceManager = (): Promise<ServiceManager<D>> => {
const { appName, spfxComponent, spfxContext, teams, serviceDescriptors } = this.props;
return ServiceManager.create(appName, spfxComponent, spfxContext, teams, {}, serviceDescriptors);
}
public render(): ReactElement<IProps<D>> {
const { children, shimmerElements } = this.props;
const { serviceManager } = this.state;
return (
<FluentThemeProvider theme={this._theme} style={fluentRootStyle}>
<Shimmer isDataLoaded={!!serviceManager} customElementsGroup={shimmerElements}>
{!!serviceManager &&
<ServicesProvider value={serviceManager.services}>
{children(serviceManager.services)}
</ServicesProvider>
}
</Shimmer>
</FluentThemeProvider>
);
}
}

View File

@ -0,0 +1,104 @@
import React, { FC, FormEvent, useCallback, useMemo } from "react";
import { duration, Duration } from "moment-timezone";
import { css, DefaultButton, Dropdown, IDropdownOption, Label } from "@fluentui/react";
import { now } from "common";
import styles from "./styles/TimePicker.module.scss";
const HoursOptions: IDropdownOption[] = [
{ key: 1, text: '1' },
{ key: 2, text: '2' },
{ key: 3, text: '3' },
{ key: 4, text: '4' },
{ key: 5, text: '5' },
{ key: 6, text: '6' },
{ key: 7, text: '7' },
{ key: 8, text: '8' },
{ key: 9, text: '9' },
{ key: 10, text: '10' },
{ key: 11, text: '11' },
{ key: 0, text: '12' }
];
const MinutesOptions: IDropdownOption[] = [
{ key: 0, text: '00' },
{ key: 15, text: '15' },
{ key: 30, text: '30' },
{ key: 45, text: '45' },
];
export interface ITimePickerProps {
className?: string;
disabled?: boolean;
label?: string;
ariaLabel?: string;
required?: boolean;
value: Duration;
onChange: (value: Duration) => void;
}
export const TimePicker: FC<ITimePickerProps> = ({
className,
disabled,
label,
ariaLabel = label,
required = false,
value = duration({ hours: now().hours() }),
onChange
}) => {
const time = useMemo(() => {
return {
hour: value.hours() % 12,
minute: Math.floor(value.minutes() / 15) * 15, // round down to the closest 15-minute increment
ampm: value.hours() >= 12
};
}, [value]);
const constructDuration = useCallback((hour: number, minute: number, ampm: boolean) =>
duration({ hours: hour + (ampm ? 12 : 0), minutes: minute })
, []);
const onHourChanged = useCallback((ev: FormEvent, option: IDropdownOption) => {
const newHour = option.key as number;
onChange(constructDuration(newHour, time.minute, time.ampm));
}, [time, onChange, constructDuration]);
const onMinuteChanged = useCallback((ev: FormEvent, option: IDropdownOption) => {
const newMinute = option.key as number;
onChange(constructDuration(time.hour, newMinute, time.ampm));
}, [time, onChange, constructDuration]);
const onAMPMClicked = useCallback(() => {
onChange(constructDuration(time.hour, time.minute, !time.ampm));
}, [time, onChange, constructDuration]);
return (
<div className={css(styles.timePicker, className)}>
{label && <Label aria-label={ariaLabel} required={required}>{label}</Label>}
<div className={styles.controls}>
<Dropdown
ariaLabel={label ? "set hour for " + label : "set hour for the time"}
disabled={disabled}
options={HoursOptions}
selectedKey={time.hour}
onChange={onHourChanged}
/>
<Dropdown
ariaLabel={label ? "set minutes for " + label : "set minutes for the time"}
disabled={disabled}
options={MinutesOptions}
selectedKey={time.minute}
onChange={onMinuteChanged}
/>
<DefaultButton
ariaLabel={time.ampm ? 'PM' : 'AM'}
disabled={disabled}
onClick={onAMPMClicked}
>
{time.ampm ? 'PM' : 'AM'}
</DefaultButton>
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
import { isEmpty } from "lodash";
import React, { FC } from "react";
import { css, Label, Persona, PersonaSize } from "@fluentui/react";
import { User } from "../User";
import styles from "./styles/UserList.module.scss";
interface IProps {
users: User[];
className?: string;
label?: string;
size?: PersonaSize;
}
const renderUser = (user: User, size: PersonaSize = PersonaSize.size24) =>
<Persona
tabIndex={0}
aria-label={user.title}
className={styles.persona}
text={user.title}
imageUrl={user.picture}
size={size}
/>;
export const UserList: FC<IProps> = ({
className,
label,
users,
size
}: IProps) => <>
{label && <Label tabIndex={0} aria-label={label}>{label}</Label>}
<div className={css(styles.personaList, className)}>
{isEmpty(users)
? <>&nbsp;</>
: users.sort(User.TitleAscComparer).map(user => renderUser(user, size))
}
</div>
</>;

View File

@ -0,0 +1,181 @@
import { isEmpty } from "lodash";
import React, { FC, useCallback, useMemo } from "react";
import { PrincipalType } from "@pnp/sp";
import { IPeoplePickerProps, ListPeoplePicker, NormalPeoplePicker, CompactPeoplePicker, IPersonaProps, Label, css, useTheme, PeoplePickerItem, IPeoplePickerItemSelectedProps, IPeoplePickerItemSelectedStyles } from '@fluentui/react';
import { IDirectoryService, useDirectoryService } from 'common/services';
import { SharePointGroup } from "common/sharepoint";
import { User } from '../User';
import { InfoTooltip } from "./InfoTooltip";
import * as cstrings from 'CommonStrings';
import styles from './styles/UserPicker.module.scss';
const maximumSuggestions = 10;
export enum UserPickerDisplayOption {
Normal,
List,
Compact
}
export type OnChangedCallback = (users: User[]) => void;
export interface IUserPickerProps {
className?: string;
label?: string;
ariaLabel?: string;
tooltip?: string;
disabled?: boolean;
required?: boolean;
display?: UserPickerDisplayOption;
users: User[];
onChanged: OnChangedCallback;
restrictPrincipalType?: PrincipalType;
restrictToGroupMembers?: SharePointGroup;
}
interface IUserPersonaProps extends IPersonaProps {
user: User;
}
const userToUserPersona = (user: User): IUserPersonaProps => {
return {
imageUrl: user.picture,
text: user.title,
secondaryText: user.email,
user: user
};
};
const containsUser = (list: User[], user: User) => {
return list.some(item => item.email === user.email);
};
const removeDuplicateUsers = (suggestedUsers: User[], currentUsers: User[]) => {
return suggestedUsers.filter(user => !containsUser(currentUsers, user));
};
const extractEmailAddress = (input: string): string => {
const emailAddress = /<.+?>/g.exec(input);
if (emailAddress && emailAddress[0]) {
return emailAddress[0].substring(1, emailAddress[0].length - 1).trim();
} else {
return input.trim();
}
};
const extractEmailAddresses = (input: string): string[] => {
return input.split(';').map(extractEmailAddress).filter(Boolean).map(e => e.toLocaleLowerCase());
};
const isListOfEmailAddresses = (input: string): boolean => {
return input.indexOf(';') !== -1 && input.length > 10;
};
const resolveSuggestions = async (searchText: string, currentUserPersonas: IUserPersonaProps[], directoryService: IDirectoryService, onChangedFn: OnChangedCallback, restrictToGroupMembers?: SharePointGroup, restrictPrincipalType?: PrincipalType): Promise<IUserPersonaProps[]> => {
if (!searchText) return [];
searchText = searchText.toLocaleLowerCase();
const currentUsers = currentUserPersonas.map(userPersona => userPersona.user);
if (isListOfEmailAddresses(searchText)) {
const extractedEmails = extractEmailAddresses(searchText);
let resolvedUsers: User[];
if (restrictToGroupMembers)
resolvedUsers = restrictToGroupMembers.members.filter(member => extractedEmails.some(email => member.email === email));
else
resolvedUsers = await directoryService.resolve(extractedEmails);
const nextUsers = [
...currentUsers,
...removeDuplicateUsers(resolvedUsers, currentUsers)
];
onChangedFn(nextUsers);
return [];
}
else {
let suggestedUsers: User[];
if (restrictToGroupMembers)
suggestedUsers = restrictToGroupMembers.members.filter(member => member.title?.toLocaleLowerCase().includes(searchText) || member.email?.toLocaleLowerCase().includes(searchText));
else
suggestedUsers = await directoryService.search(searchText, restrictPrincipalType);
suggestedUsers = suggestedUsers.slice(0, maximumSuggestions);
return removeDuplicateUsers(suggestedUsers, currentUsers).map(userToUserPersona);
}
};
const UserPicker: FC<IUserPickerProps> = ({
className,
display = UserPickerDisplayOption.Normal,
disabled,
label,
ariaLabel,
required,
tooltip,
users,
onChanged,
restrictToGroupMembers,
restrictPrincipalType
}) => {
const { palette: { neutralLight } } = useTheme();
const directory = useDirectoryService();
const userPersonas = users.map(userToUserPersona);
const role = !isEmpty(userPersonas) ? "list" : "none";
const onChange = (items: IPersonaProps[]) => {
if (!disabled)
onChanged((items as IUserPersonaProps[]).map(userPersona => userPersona.user));
};
const onResolveSuggestions = (filter: string, selectedItems: IPersonaProps[]) =>
resolveSuggestions(filter, selectedItems as IUserPersonaProps[], directory, onChanged, restrictToGroupMembers, restrictPrincipalType);
const fixHighContrastPeoplePickerItemStyles = useMemo(() => {
return { root: { backgroundColor: neutralLight } } as IPeoplePickerItemSelectedStyles;
}, [neutralLight]);
const onRenderItem = useCallback(
(props: IPeoplePickerItemSelectedProps) => <PeoplePickerItem {...props} styles={fixHighContrastPeoplePickerItemStyles} />,
[fixHighContrastPeoplePickerItemStyles]
);
const renderPicker = () => {
const peoplePickerProps: IPeoplePickerProps = {
selectedItems: userPersonas,
onResolveSuggestions,
onChange,
disabled,
inputProps: { 'aria-label': ariaLabel || label },
removeButtonAriaLabel: cstrings.UserPicker.RemoveAriaLabel,
onRenderItem
};
switch (display) {
case UserPickerDisplayOption.Normal:
return <NormalPeoplePicker {...peoplePickerProps} />;
case UserPickerDisplayOption.List:
return <ListPeoplePicker {...peoplePickerProps} />;
case UserPickerDisplayOption.Compact:
return <CompactPeoplePicker {...peoplePickerProps} />;
}
};
return (
<div className={css(styles.userPicker, className)} aria-label={ariaLabel || label} role={role}>
{label &&
<InfoTooltip text={tooltip}>
<Label className={styles.label} required={required}>{label}</Label>
</InfoTooltip>
}
{renderPicker()}
</div>
);
};
export default UserPicker;

View File

@ -0,0 +1,48 @@
import { isFunction } from "lodash";
import React from "react";
import { css, DelayedRender } from "@fluentui/react";
import { ErrorIcon } from "@fluentui/react-icons-mdl2";
import { Entity } from "../Entity";
import { ValidationRule } from "../ValidationRules";
export interface IValidationProps<E extends Entity<any>> extends React.HTMLAttributes<HTMLElement | React.FC<IValidationProps<E>>> {
active: boolean;
entity: E;
rules: ValidationRule<E>[];
}
export const Validation = <E extends Entity<any>>(props: IValidationProps<E>) => {
const {
active,
entity,
rules = [],
children
} = props;
let valid = true;
let failMessage = "";
rules.filter(Boolean).forEach(rule => {
if (valid) {
valid = rule.validate(entity);
if (!valid) {
failMessage = isFunction(rule.failMessage) ? rule.failMessage(entity) : rule.failMessage;
}
}
});
return (
<div className={css(props.className, { "validation-error": active && !valid })}>
{children}
{active && !valid &&
<DelayedRender>
<p className="error-message ms-font-s ms-fontColor-redDark ms-slideDownIn20">
<ErrorIcon />
&nbsp;
<span aria-live='assertive' data-automation-id='error-message'>{failMessage}</span>
</p>
</DelayedRender>
}
</div>
);
};

View File

@ -0,0 +1,20 @@
import React from "react";
import { css, Label } from '@fluentui/react';
import styles from "./styles/WebPartTitle.module.scss";
export interface IWebPartTitleProps {
title: string;
className?: string;
show?: boolean;
children?: React.ReactNode;
}
export const WebPartTitle: React.FC<IWebPartTitleProps> = ({ className, show = true, title, children }: IWebPartTitleProps) => {
return (
<div className={css(styles.webPartTitle, className)}>
{show && <Label><h2 role="heading" aria-level={2} tabIndex={0}>{title}</h2></Label>}
{children}
</div>
);
};

View File

@ -0,0 +1,458 @@
import { noop } from "lodash";
import React, { Component, ReactElement, ReactNode } from "react";
import { css, DefaultButton, PrimaryButton, IconButton, MessageBar, MessageBarType, Spinner, SpinnerSize, Stack, StackItem } from "@fluentui/react";
import { CircleRingIcon, CompletedSolidIcon, RadioBtnOnIcon } from "@fluentui/react-icons-mdl2";
import { IWizardStrings } from "../Localization";
import * as cstrings from "CommonStrings";
import styles from "./styles/Wizard.module.scss";
export type WizardData = {};
export interface IButtonRenderProps<D extends WizardData> {
defaultBackButton: ReactNode;
defaultNextButton: ReactNode;
disabled: boolean;
isFowardOnly: boolean;
isFirstStep: boolean;
isLastStep: boolean;
wizardStrings: IWizardStrings;
data: D;
onBack: () => void;
onNext: () => void;
}
export interface IWizardPageMetadata<D extends WizardData> {
title?: string;
forwardOnly?: boolean;
onRenderButtons?: (props: IButtonRenderProps<D>) => ReactNode;
}
export interface IWizardPageProps<D extends WizardData> {
data: D;
onClickEdit?: (pageIndex: number) => void;
children?: React.ReactNode;
}
export interface IWizardStepProps<D extends WizardData> extends IWizardPageProps<D> {
stepNumber?: number;
totalStepCount?: number;
validateFn: (fn: () => boolean) => void;
deactivateFn: (fn: () => Promise<any>) => void;
}
export type PageRenderer<D extends WizardData, P extends IWizardPageProps<D> = IWizardPageProps<D>> = React.FC<P>;
export type StepRenderer<D extends WizardData, P extends IWizardStepProps<D> = IWizardStepProps<D>> = PageRenderer<D, P> & IWizardPageMetadata<D>;
export interface IWizardProps<D extends WizardData> {
data: D;
headingLabel?: string;
heading?: ReactElement;
className?: string;
panel?: boolean;
strings?: Partial<IWizardStrings>;
startPage?: PageRenderer<D>;
stepPages: StepRenderer<D>[];
successPage?: PageRenderer<D>;
successPageTimeout?: number;
execute?: (config: D) => Promise<void>;
initialize?: () => Promise<void>;
onWizardComplete?: () => void;
onDiscard?: () => void;
}
export interface IWizardState {
currentPageIndex: number;
error: any;
}
abstract class WizardPage<D extends WizardData, P extends IWizardPageProps<D> = IWizardPageProps<D>> {
constructor(
private readonly _renderer: PageRenderer<D, P>,
protected readonly wizardStrings: IWizardStrings
) {
}
public async activate(): Promise<void> {
}
public valid(): boolean {
return true;
}
public async deactivate(): Promise<void> {
}
public get autoContinue(): boolean {
return false;
}
public renderPage(props: P): React.ReactNode {
const Page = this._renderer;
return <Page {...props} />;
}
public renderFooterButtons(props: IWizardPageProps<D>, disabled: boolean): ReactNode {
return <></>;
}
}
class WizardStartPage<D extends WizardData> extends WizardPage<D> {
constructor(
renderer: PageRenderer<D>,
wizardStrings: IWizardStrings,
private readonly _onClickStart: () => void
) {
super(renderer, wizardStrings);
}
public renderFooterButtons(props: IWizardPageProps<D>, disabled: boolean): ReactNode {
return <>
<PrimaryButton text={this.wizardStrings.StartButton.Text} disabled={disabled} onClick={this._onClickStart} />
</>;
}
}
class WizardStepPage<D extends WizardData> extends WizardPage<D, IWizardStepProps<D>> {
private _validateFn: () => boolean;
private _deactivateFn: () => Promise<any>;
constructor(
public readonly renderer: StepRenderer<D>,
wizardStrings: IWizardStrings,
private readonly _stepNumber: number,
private readonly _totalStepCount: number,
private readonly _onClickBack: () => void,
private readonly _onClickNext: () => void
) {
super(renderer, wizardStrings);
}
public valid(): boolean {
return this._validateFn ? this._validateFn() : true;
}
public async deactivate(): Promise<void> {
if (this._deactivateFn)
await this._deactivateFn();
}
protected get isStep(): boolean {
return !!this._stepNumber;
}
protected get isFirstStep(): boolean {
return this._stepNumber === 1;
}
protected get isLastStep(): boolean {
return this._stepNumber === this._totalStepCount;
}
public renderPage(props: IWizardStepProps<D>): React.ReactNode {
if (this.isStep) {
props.stepNumber = this._stepNumber;
props.totalStepCount = this._totalStepCount;
}
return super.renderPage({
...props,
validateFn: fn => this._validateFn = fn,
deactivateFn: fn => this._deactivateFn = fn
});
}
public renderFooterButtons(props: IWizardPageProps<D>, disabled: boolean): ReactNode {
const { BackButton, NextButton, FinishButton } = this.wizardStrings;
const backButtonText = BackButton.Text;
const nextButtonText = this.isLastStep ? FinishButton.Text : NextButton.Text;
const isFowardOnly = !!this.renderer.forwardOnly;
let defaultBackButton: ReactNode;
let defaultNextButton: ReactNode;
if (this.isFirstStep || isFowardOnly) {
defaultBackButton = <></>;
defaultNextButton = <PrimaryButton text={nextButtonText} disabled={disabled} onClick={this._onClickNext} />;
} else {
defaultBackButton = <DefaultButton text={backButtonText} disabled={this.isFirstStep || disabled} onClick={this._onClickBack} />;
defaultNextButton = <PrimaryButton text={nextButtonText} disabled={disabled} onClick={this._onClickNext} />;
}
const renderProps: IButtonRenderProps<D> = {
data: props.data,
defaultBackButton,
defaultNextButton,
disabled,
isFowardOnly,
isFirstStep: this.isFirstStep,
isLastStep: this.isLastStep,
wizardStrings: this.wizardStrings,
onBack: this._onClickBack,
onNext: this._onClickNext
};
const { onRenderButtons } = this.renderer;
return onRenderButtons ? onRenderButtons(renderProps) : this._defaultRenderFooterButtons(renderProps);
}
private _defaultRenderFooterButtons(props: IButtonRenderProps<D>): ReactNode {
const { defaultBackButton, defaultNextButton } = props;
return <>
{defaultBackButton}
{defaultNextButton}
</>;
}
}
class WizardInitializePage<D extends WizardData> extends WizardPage<D> {
constructor(
wizardStrings: IWizardStrings,
private readonly _initialize: () => Promise<void>
) {
super(null, wizardStrings);
}
public async activate() {
await this._initialize();
}
public get autoContinue(): boolean {
return true;
}
public renderPage(props: IWizardPageProps<D>): React.ReactNode {
return <Spinner size={SpinnerSize.large} label={cstrings.OneMoment} />;
}
}
class WizardExecutePage<D extends WizardData> extends WizardPage<D> {
constructor(
wizardStrings: IWizardStrings,
private readonly _execute: () => Promise<void>
) {
super(null, wizardStrings);
}
public async activate() {
await this._execute();
}
public get autoContinue(): boolean {
return true;
}
public renderPage(props: IWizardPageProps<D>): React.ReactNode {
return <Spinner style={{ marginTop: 20 }} size={SpinnerSize.large} label={cstrings.OneMoment} />;
}
}
class WizardSuccessPage<D extends WizardData> extends WizardPage<D> {
constructor(
step: PageRenderer<D>,
wizardStrings: IWizardStrings,
private readonly _timeout: number,
private readonly _onWizardComplete: () => void
) {
super(step, wizardStrings);
}
public async activate() {
setTimeout(this._onWizardComplete, this._timeout);
}
}
export class Wizard<D extends WizardData> extends Component<IWizardProps<D>, IWizardState> {
private static readonly defaultProps: Partial<IWizardProps<any>> = {
successPageTimeout: 2500,
onWizardComplete: noop
};
private readonly _pages: WizardPage<D>[];
constructor(props: IWizardProps<D>) {
super(props);
const wizardStrings = { ...cstrings.Wizard, ...props.strings };
this._pages = this._buildPages(props, wizardStrings);
this.state = {
currentPageIndex: -1,
error: null
};
}
public componentDidMount() {
this._nextPage();
}
private readonly _goToPage = async (pageIndex: number) => {
const currentPageIndex = this.state.currentPageIndex;
const currentPage = this._pages[currentPageIndex];
const isValid = currentPage ? currentPage.valid() : true;
if (isValid && pageIndex < this._pages.length) {
if (currentPage) {
await currentPage.deactivate();
}
const newPage = this._pages[pageIndex];
this.setState({
currentPageIndex: pageIndex
});
try {
await newPage.activate();
} catch (e) {
console.error(e);
this.setState({ error: e });
}
}
}
private readonly _buildPages = (props: IWizardProps<D>, wizardStrings: IWizardStrings): WizardPage<D>[] => {
const pages: WizardPage<D>[] = [];
if (props.startPage) {
const page = new WizardStartPage(props.startPage, wizardStrings, this._nextPage);
pages.push(page);
}
if (props.initialize) {
const page = new WizardInitializePage<D>(wizardStrings, props.initialize);
pages.push(page);
}
props.stepPages.forEach((step, index, steps) => {
const page = new WizardStepPage(step, wizardStrings, index + 1, steps.length, this._previousPage, this._nextPage);
pages.push(page);
});
if (props.execute) {
const page = new WizardExecutePage<D>(wizardStrings, () => props.execute(props.data));
pages.push(page);
}
if (props.successPage) {
const page = new WizardSuccessPage(props.successPage, wizardStrings, props.successPageTimeout, props.onWizardComplete);
pages.push(page);
}
return pages;
}
private readonly _previousPage = () => {
const currentPageIndex = this.state.currentPageIndex;
const newPageIndex = currentPageIndex - 1;
const currentPage = this._pages[currentPageIndex];
const isValid = currentPage ? currentPage.valid() : true;
if (isValid) {
if (newPageIndex >= 0) {
this._pages[currentPageIndex].deactivate();
this._pages[newPageIndex].activate();
this.setState({
currentPageIndex: newPageIndex
});
}
}
}
private readonly _nextPage = async () => {
const currentPageIndex = this.state.currentPageIndex;
const newPageIndex = currentPageIndex + 1;
const isLastPage = currentPageIndex === this._pages.length - 1;
const currentPage = this._pages[currentPageIndex];
const isValid = currentPage ? currentPage.valid() : true;
if (isValid) {
if (currentPage) {
await currentPage.deactivate();
}
if (isLastPage) {
this.props.onWizardComplete();
} else {
const newPage = this._pages[newPageIndex];
this.setState({
currentPageIndex: newPageIndex
});
try {
await newPage.activate();
} catch (e) {
console.error(e);
this.setState({ error: e });
}
if (newPage.autoContinue) {
this._nextPage();
}
}
}
}
private readonly _renderProgressBar = () => {
const { onDiscard } = this.props;
const { currentPageIndex } = this.state;
const stepPages = this._pages.filter(page => page instanceof WizardStepPage).map(page => page as WizardStepPage<D>);
if (this._pages[currentPageIndex] instanceof WizardStartPage || stepPages.length === 0)
return;
return (
<Stack className={styles.progressBar} horizontal horizontalAlign='space-evenly'>
{stepPages.map((page, idx) => {
const PageIcon = idx < currentPageIndex ? CompletedSolidIcon : (idx === currentPageIndex ? RadioBtnOnIcon : CircleRingIcon);
const className = css(styles.statusIndicator, { [styles.futurePage]: idx > currentPageIndex });
return (
<StackItem key={idx} grow>
<PageIcon className={className} />
<div>{page.renderer.title}</div>
</StackItem>
);
})}
{onDiscard && <StackItem>
<IconButton
ariaLabel={cstrings.Wizard.CloseButtonAriaLabel}
iconProps={{ iconName: 'Cancel' }}
onClick={onDiscard}
/>
</StackItem>}
</Stack>
);
}
public render(): React.ReactElement<IWizardProps<D>> {
const { data, className, headingLabel, heading } = this.props;
const { currentPageIndex, error } = this.state;
const currentPage = this._pages[currentPageIndex];
const pageProps: IWizardPageProps<D> = {
data,
onClickEdit: this._goToPage
};
return (
<div className={css(styles.wizard, className)}>
<div className={styles.header}>
{heading || (headingLabel && <h1>{headingLabel}</h1>)}
</div>
{this._renderProgressBar()}
{!error
? currentPage?.renderPage(pageProps)
: <MessageBar messageBarType={MessageBarType.error}>{cstrings.GenericError}</MessageBar>
}
<Stack horizontal horizontalAlign="center" wrap className={styles.footer} tokens={{ childrenGap: 10 }}>
{currentPage?.renderFooterButtons(pageProps, !!error)}
</Stack>
</div>
);
}
}

View File

@ -0,0 +1,37 @@
export { AsyncDataComponent } from './AsyncDataComponent';
export { AsyncOverlay } from './AsyncOverlay';
export { ConfirmDialog } from './ConfirmDialog';
export { CalendarPicker, CalendarDefaultStrings } from "./CalendarPicker";
export { CalloutColorPicker } from "./CalloutColorPicker";
export { DataDialogBase, IDataDialogBase, IDataDialogBaseProps, IDataDialogBaseState, DataDialogMode } from './DataDialogBase';
export { DataPanelBase, IDataPanelBase, IDataPanelBaseProps, IDataPanelBaseState, DataPanelMode, UpdateDataCallback } from './DataPanelBase';
export { DataComponentBase, IDataComponentBase, IDataComponentBaseProps, IDataComponentBaseState, DataComponentMode } from './DataComponentBase';
export { DateRotator } from './DateRotator';
export { EntityDialogBase, IEntityDialogProps } from './EntityDialogBase';
export { EntityPanelBase, IEntityPanelProps } from './EntityPanelBase';
export { EntityComponentBase, IEntityComponentProps } from './EntityComponentBase';
export { InfoTooltip } from './InfoTooltip';
export { LengthLimitedTextField } from './LengthLimitedTextField';
export { LengthOfTimePicker } from './LengthOfTimePicker';
export { default as LiveCheckbox } from './LiveCheckbox';
export { default as LiveChoiceGroup } from './LiveChoiceGroup';
export { default as LiveComboBox } from './LiveComboBox';
export { default as LiveDatePicker } from './LiveDatePicker';
export { default as LiveDropdown } from './LiveDropdown';
export { default as LiveMultiselectDropdown } from './LiveMultiselectDropdown';
export { default as LiveRelationship } from './LiveRelationship';
export { default as LiveText } from './LiveText';
export { default as LiveTextField } from './LiveTextField';
export { default as LiveTimePicker } from './LiveTimePicker';
export { default as LiveToggle } from './LiveToggle';
export { default as LiveUpdate, ITransformer, ILiveUpdateMarkProps, StateType } from './LiveUpdate';
export { default as LiveUserPicker } from './LiveUserPicker';
export { firstTextPart, Localize } from './Localize';
export { ResponsiveGrid, GridRow, GridCol } from './ResponsiveGrid';
export { SharePointApp } from './SharePointApp';
export { TimePicker } from './TimePicker';
export { UserList } from './UserList';
export { default as UserPicker, UserPickerDisplayOption } from './UserPicker';
export { Validation } from './Validation';
export { WebPartTitle } from './WebPartTitle';
export { Wizard, IWizardPageProps, IWizardStepProps, IButtonRenderProps, PageRenderer, StepRenderer } from './Wizard';

View File

@ -0,0 +1,88 @@
declare module 'CommonStrings' {
import { IButtonStrings, IDialogStrings, IWizardStrings } from "../Localization";
interface IHumanizeStrings {
ZeroCount: string;
HourShort: string;
HoursShort: string;
MinuteShort: string;
MinutesShort: string;
ListSeparator: string;
ListConjunction: string;
ListExcept: string;
ListAllItems: string;
}
interface IValidationStrings {
ValidationFailed: string;
Required: string;
MinimumValue: string;
MaximumValue: string;
RangeValue: string;
MaximumLength: string;
MaximumItems: string;
Url: string;
Email: string;
Phone: string;
}
interface IDataRotatorStrings {
PreviousDateButton: IButtonStrings;
NextDateButton: IButtonStrings;
}
interface IUserPickerStrings {
RemoveAriaLabel: string;
}
interface ILiveUpdateCalloutStrings {
ItemWasAdded: string;
ItemWasEdited: string;
ItemWasDeleted: string;
MyChangeLabel: string;
TheirChangeLabel: string;
OriginalLabel: string;
KeepMineButton: IButtonStrings;
KeepTheirsButton: IButtonStrings;
TakeTheirsButton: IButtonStrings;
RevertToOriginalButton: IButtonStrings;
UndeleteButton: IButtonStrings;
}
interface ILiveToggleStrings {
OnText: string;
OffText: string;
}
interface ILiveUpdateStrings {
New: string;
Updated: string;
Deleted: string;
RecentlyAddedMarkTooltip: string;
RecentlyEditedMarkTooltip: string;
RecentlyDeletedMarkTooltip: string;
Callout: ILiveUpdateCalloutStrings;
Toggle: ILiveToggleStrings;
}
interface ICommonStrings {
Loading: string;
Saving: string;
OneMoment: string;
GenericError: string;
GenericEmptyListMessage: string;
Close: string;
Humanize: IHumanizeStrings;
Validation: IValidationStrings;
ConfirmDialogDefaults: IDialogStrings;
ConfirmDeleteDialog: IDialogStrings;
ConfirmDiscardDialog: IDialogStrings;
DateRotator: IDataRotatorStrings;
Wizard: IWizardStrings;
UserPicker: IUserPickerStrings;
LiveUpdate: ILiveUpdateStrings;
}
const strings: ICommonStrings;
export = strings;
}

View File

@ -0,0 +1,90 @@
define([], function () {
return {
Loading: "Loading...",
Saving: "Saving...",
OneMoment: "One moment...",
GenericError: "Sorry, something went wrong.",
GenericEmptyListMessage: "We can't find anything to show here.",
Close: "Close",
Humanize: {
ZeroCount: "no {0}",
HourShort: "hr",
HoursShort: "hrs",
MinuteShort: "min",
MinutesShort: "mins",
ListSeparator: ",",
ListConjunction: "and",
ListExcept: "except",
ListAllItems: "All items"
},
Validation: {
ValidationFailed: "Please fix all validation errors.",
Required: "This field is required.",
MinimumValue: "This field cannot be less than {0}.",
MaximumValue: "This field cannot be greater than {0}.",
RangeValue: "This field must be from {0} and {1}.",
MaximumLength: "This field cannot have more than {0} characters.",
MaximumItems: "This cannot have more than {0} items.",
Url: "This field must be a valid URL.",
Email: "This field must be a valid e-mail address.",
Phone: "This field must be a valid US phone number"
},
ConfirmDialogDefaults: {
HeadingText: "Confirm",
MessageText: "Are you sure?",
AcceptButton: { Text: "OK" },
RejectButton: { Text: "Cancel" }
},
ConfirmDeleteDialog: {
HeadingText: "Delete",
MessageText: "Are you sure you want to delete?",
AcceptButton: { Text: "Delete" },
RejectButton: { Text: "Cancel" }
},
ConfirmDiscardDialog: {
HeadingText: "Discard",
MessageText: "Are you sure you want to discard changes?",
AcceptButton: { Text: "Discard" },
RejectButton: { Text: "Keep Editing" }
},
DateRotator: {
PreviousDateButton: { Text: "Previous date" },
NextDateButton: { Text: "Next date" },
},
Wizard: {
StartButton: { Text: "Start" },
BackButton: { Text: "Back" },
NextButton: { Text: "Next" },
FinishButton: { Text: "Finish" },
CloseButtonAriaLabel: "close wizard button"
},
UserPicker: {
RemoveAriaLabel: "Remove"
},
LiveUpdate: {
New: "New!",
Updated: "Updated",
Deleted: "Deleted",
RecentlyAddedMarkTooltip: "Someone added this item recently",
RecentlyEditedMarkTooltip: "Other people have made edits recently",
RecentlyDeletedMarkTooltip: "Someone deleted this item",
Callout: {
ItemWasAdded: "{0} added this item {1}",
ItemWasEdited: "{0} made edits {1}",
ItemWasDeleted: "This item was deleted",
MyChangeLabel: "My edit",
TheirChangeLabel: "Their change",
OriginalLabel: "Original",
KeepMineButton: { Text: "Keep mine" },
KeepTheirsButton: { Text: "Keep theirs" },
TakeTheirsButton: { Text: "Take theirs" },
RevertToOriginalButton: { Text: "Revert to original" },
UndeleteButton: { Text: "Restore this item" }
},
Toggle: {
OnText: "On",
OffText: "Off"
}
}
};
});

View File

@ -0,0 +1,22 @@
@import 'common.module';
$spinner-size: 28px /*SpinnerSize.large*/ + 18px /*spinner label size*/;
$spinner-padding: 20px;
.asyncLoadComponent {
position: relative;
overflow: hidden;
&.spinnersEnabled {
min-height: $spinner-size + $spinner-padding * 2;
}
.errorMessage {
background-color: lightpink;
padding: 10px;
}
.detailsLink {
color: $ms-color-sharedCyanBlue10;
}
}

View File

@ -0,0 +1,13 @@
@import 'common.module';
.asyncOverlay {
&:global(.ms-Overlay) {
display: flex;
flex-direction: column;
justify-content: center;
}
:global(.ms-Spinner) {
padding: 20px;
}
}

Some files were not shown because too many files have changed in this diff Show More