Merge branch 'pnp:main' into main
This commit is contained in:
commit
b5025e6ffe
|
@ -10,19 +10,19 @@ This sample is the source code for the Rhythm of Business Calendar app published
|
|||
|
||||
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
|
||||
Month view
|
||||
![Screenshot of month view](./assets/screenshot-month-view.png)
|
||||
|
||||
View event details
|
||||
View event details
|
||||
![Screenshot of view event panel](./assets/screenshot-view-event.png)
|
||||
|
||||
Edit refiner
|
||||
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)
|
||||
![Node.js v16](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")
|
||||
|
@ -77,15 +77,6 @@ TODO: add support for containers
|
|||
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
|
||||
|
@ -95,7 +86,6 @@ At a high-level, the accelerator includes the following features:
|
|||
* 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.
|
||||
-->
|
||||
|
||||
|
||||
<!--
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"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."
|
||||
],
|
||||
"creationDateTime": "2022-09-26",
|
||||
"updateDateTime": "2022-11-03",
|
||||
"updateDateTime": "2022-11-12",
|
||||
"products": [
|
||||
"SharePoint",
|
||||
"Microsoft Teams"
|
||||
|
|
|
@ -1,17 +1,45 @@
|
|||
# SPFx Solution Accelerator Deep Dive
|
||||
This section is a deep dive in to various aspects of the SPFx Solution Accelerator and how to use these features for building robust enterprise applications on SharePoint with SPFx.
|
||||
|
||||
## Solution Structure
|
||||
## [Solution Structure](./solution-structure.md)
|
||||
The prescribed folder structure helps to organize the application code in to layers with clear dependencies, that is similar to Onion Architecture.
|
||||
|
||||
## Build Tools
|
||||
[Read more](./solution-structure.md)
|
||||
|
||||
## Entities
|
||||
## [Build Tools](./build-tools.md)
|
||||
We have extended the SPFx gulp tasks to support the concept of environments and to simplify the commands for building and deploying solutions.
|
||||
|
||||
## Services
|
||||
[Read more](./build-tools.md)
|
||||
|
||||
## Schema
|
||||
## [Entities](./entities.md)
|
||||
Entities are the implementation of a rich domain model for your application, inspired by the Domain-Driven Design approach to software development.
|
||||
|
||||
## Components
|
||||
[Read more](./entities.md)
|
||||
|
||||
## Live Update
|
||||
## [Services](./services.md)
|
||||
The SPFx Solution accelerator includes its own services framework. The app specifies which services it needs using descriptors (objects that describe the services), the `ServiceManager` handles creating and initializing the specified services for the specific runtime environment (modern, classic, local, or test), and a React provider using the Context API enables components to consume services. Components indicate the specific services they need and those services are available through props or hooks.
|
||||
|
||||
## Fast Load Caching
|
||||
[Read more](./services.md)
|
||||
|
||||
## [Schema](./schema.md)
|
||||
Schema refers to the SharePoint elements that need to be provisioned and configured on the site for the app to function and be able to securely store data, such as lists, views and columns, and even custom security groups. The SPFx Solution Accelerator includes robust patterns and utilities for defining, provisioning, and upgrading the app's schema.
|
||||
|
||||
[Read more](./schema.md)
|
||||
|
||||
## [Components](./components.md)
|
||||
The SPFx Solution Accelerator includes a few components we've found useful over the years for building enterprise apps that integrate with the services and domain model aspects of the accelerator, from implementing asynchronous data patterns and view-edit-save-or-discard flows, to building responsive and localized apps.
|
||||
|
||||
[Read more](./components.md)
|
||||
|
||||
<!--
|
||||
|
||||
## [Live Update](./live-update.md)
|
||||
[Read more](./live-update.md)
|
||||
|
||||
## [Fast Load Caching](./fast-load-caching.md)
|
||||
[Read more](./fast-load-caching.md)
|
||||
|
||||
## [Potential enhancements](./future-enhancements.md)
|
||||
[Read more](./future-enhancements.md)
|
||||
|
||||
-->
|
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
|
@ -1,3 +1,123 @@
|
|||
# Build Tools
|
||||
We have extended the SPFx gulp tasks to support the concept of environments and to simplify the commands for building and deploying solutions.
|
||||
|
||||
Coming soon
|
||||
A prerequisite for using these commands is to install the PnP PowerShell module on your machine.
|
||||
|
||||
From an elevated PowerShell prompt, run the following:
|
||||
`Install-Module -Name PnP.PowerShell`
|
||||
|
||||
The code for all of the gulp tasks can all be found in the [\build\gulpfile.ts](../build/gulpfile.ts) file.
|
||||
|
||||
## Commands
|
||||
The package command does a clean, build, bundle, and package-solution for a specific environment all in one comand. The standard .sppkg package file is generated, as well as a .zip file with the project's source code suitable for submitting to another party for review of the app's code.
|
||||
|
||||
Run the package command for the production environment:
|
||||
`gulp package --ship --env prod`
|
||||
|
||||
The deploy command does everything the package command does (clean build, bundle, and package-solution), plus it will deploy the solution to the app catalog specified by the environment.
|
||||
|
||||
Run the deploy command for the dev environment:
|
||||
`gulp deploy --ship --env dev`
|
||||
|
||||
It uses commands from PnP PowerShell to upload and deploy the solution.
|
||||
|
||||
## Environments
|
||||
|
||||
The project root contains a file named [environments.json](../environments.json) where you can define all of your environments for developing, testing, and deploying your app.
|
||||
|
||||
Here is an example of how to declare the dev environment:
|
||||
```
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
* Environment name - the environment name can be anything you want. In addition to local/dev/test/prod, we often create environments specific to each developer on the team, using their alias for the environment name.
|
||||
* **deploySiteUrl** - this is the site that contains app catalog. It can be a site collection app catalog or tenant app catalog.
|
||||
* **deployScope** - either "Site" or "Tenant" - indicates whether the deploySiteUrl is site collection app catalog or tenant app catalog
|
||||
* **skipFeatureDeployment** - if the solution supports skipping feature deployment (skipFeatureDeployment flag in package-solution.json), setting this parameter to true will cause the -SkipFeatureDeployment flag to the be specified when running the Add-PnPApp command during deployment
|
||||
* **environmentSymbol** - this value is used to temporarily set any variable named `Environment` in any file(s) named Defaults.ts throughout the solution when using the package or deploy commands. Defaults.ts files are described below.
|
||||
* package - specifies values that will be temporarily replaced in the solution-package.json file when using the package or deploy commands
|
||||
* **name** - the display name of the app. Our pattern is to prepend the ALL CAPS name of the environment to the production name ('**DEV** Rhythm of Business Calendar').
|
||||
* **id** - a distinct GUID for the package for this environment. [https://www.guidgenerator.com/](https://www.guidgenerator.com/) is a useful tool for generating GUIDs.
|
||||
* **filename** - the filename to use when generating the sppkg for this environment. Our pattern is to append the ALL CAPS name of the environment to the production name ('RhythmOfBusinessCalendar-**DEV**.sppkg').
|
||||
* webparts - this is an array of all web parts in the solution and specifies values that will be temporarily replaced in the web part manifest file when using the package or deploy commands
|
||||
* **manifest** - this value is used to identify the web part manifest file to modify during the package or deploy command. The name should be the actual filename of the web part manifest and should be not be altered with the environment name.
|
||||
* **id** - a distinct GUID for the web part for this environment. [https://www.guidgenerator.com/](https://www.guidgenerator.com/) is a useful tool for generating GUIDs.
|
||||
* **alias** - the alias of the web part for this environment. Our pattern is to append the ALL CAPS name of the environment to the production alias ('RhythmOfBusinessCalendarWebPart**DEV**').
|
||||
* **title** - the display name of the web part for this environment. Our pattern is to prepend the ALL CAPS name of the environment to the production title ('**(DEV)** Rhythm of Business Calendar').
|
||||
|
||||
If the package or deploy commands ever fail (for example, due to a build error), and the package-solution.json, web part manifest, and Defaults.ts files have not reverted to the local environment values, run this command to revert those files:
|
||||
`gulp modify-env-config --env local`
|
||||
|
||||
## Defaults.ts files
|
||||
Defaults files are useful for applying static configuration in code on a per-environment basis. For example, if the team must share a single site for development and each developer has their own environment, the Defaults file can define a prefix to use when provisioning lists for the app, so that each developer can run the app on the same site entirely independent of each other.
|
||||
|
||||
A good example is the [Defaults.ts](../src/schema/Defaults.ts) in the schema folder. It defines the list titles for the app and specifies prefixes to use for each environment.
|
||||
|
||||
These files may be located anywhere in the code under the /src folder and may contain any code you like, however one line of code must declare a const value named 'Environment':
|
||||
`const Environment = Environments.LOCAL;`
|
||||
This variable is assigned the value from the environmentSymbol parameter from the environments.json file of the environment specified on the command line when running the package or deploy commands.
|
||||
|
||||
## Development process
|
||||
The typical development lifecycle is as follows:
|
||||
1. Write some code
|
||||
1. Run the code using the workbench page of a dev site in your tenant (gulp serve --nobrowser). This uses the LOCAL environment configuration.
|
||||
1. Once the code is working, deploy the app using the DEV environment (gulp deploy --ship --env dev), and add the web part to a site page. This version uses the DEV environment configuration. It does not use gulp serve to serve the script files.
|
||||
1. Once the feature is ready for testing, deploy the app using the TEST environment (gulp deploy --ship --env test).
|
||||
1. From here, the app can be deployed using the STAGE environment for final confirmation before deploying to production
|
||||
1. To deploy to production, the package command can be used to generate the SPPKG and a ZIP of the source code for review and deployment by the tenant admins, or if you are the tenant admin you can configure the PROD environment to deploy directly to your tenant app catalog.
|
||||
|
||||
|
||||
Tenant app catalog with TEST/STAGE/PROD packages all uploaded side-by-side
|
||||
![Tenant app catalog screenshot](./assets/app-catalog.png)
|
||||
|
||||
CI/CD can also be utilized to execute the gulp commands and automatically build and deploy the app to different environments.
|
||||
|
||||
This feature of the SPFx Solution Accelerator evolved to avoid conflicts with packages and web part names and IDs when multiple developers need to share a site or a tenant and the team is following the traditional dev/test/stage/prod development lifecycle.
|
||||
|
||||
The ability to alter list names and such has become less of a concern over the years, as dev/test sites and even entire tenants are easy to create for development purposes, however the feature still has some use cases.
|
||||
|
||||
## Setting up a new project
|
||||
When setting up a new project, one of the first things to do is to change the names and GUIDs in the solution files and the environments.json file. The values in the package-solution.json and each web part manifest should match the values specified for the local environment in the environments.json file.
|
||||
|
||||
```
|
||||
"local": {
|
||||
"environmentSymbol": "Environments.LOCAL",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
package-solution.json
|
||||
![package-solution.json file contents](./assets/package-solution-json-local-env.png)
|
||||
|
||||
RhythmOfBusinessCalendarWebPart.manifest.json
|
||||
![RhythmOfBusinessCalendarWebPart.manifest.json file contents](./assets/webpart-manifest-local-env.png)
|
||||
|
|
|
@ -1,3 +1,224 @@
|
|||
# Components
|
||||
The SPFx Solution Accelerator includes a few components we've found useful over the years for building enterprise apps. They integrate with the services and domain model aspects of the accelerator, for requirements ranging from implementing asynchronous data patterns and view-edit-save-or-discard flows, to building responsive and localized apps.
|
||||
|
||||
Coming soon
|
||||
All components should be WCAG 2.1 compliant.
|
||||
|
||||
## Validation
|
||||
The `Validation` component implements a simple and consistent pattern for displaying validation messages to the user. Simply wrap the input control in a Validation component and pass the relevant validation rules from the entity, and the validation component will display the error message under the input control when the input value is invalid.
|
||||
|
||||
Suppose we have the following validation rules configured for our entity:
|
||||
```
|
||||
import { ValidationRule, RequiredValidationRule } from 'common';
|
||||
|
||||
...
|
||||
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
public static readonly TitleValidations = [
|
||||
new RequiredValidationRule<Category>(e => e.title),
|
||||
];
|
||||
|
||||
...
|
||||
|
||||
protected validationRules(): ValidationRule<Category>[] {
|
||||
return [
|
||||
...Category.TitleValidations,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then we build an editor UI with a text field where the user can change the title of the entity:
|
||||
```
|
||||
const category: Category = ...;
|
||||
|
||||
<Validation entity={category} rules={Category.TitleValidations}>
|
||||
<TextField label="Title" required value={...} onChange={...} />
|
||||
</Validation>
|
||||
```
|
||||
|
||||
If the user fails to provide a value for the title field, the validation failure message will display below the component:
|
||||
![example of the Validation component in action](./assets/component-validation.png)
|
||||
|
||||
Refer to [Entities - Validation](./entities.md#validation) for more details on defining and configuring validation rules.
|
||||
|
||||
## Responsive Grid
|
||||
The `ResponsiveGrid` component is a thin wrapper around the Fluent UI responsive grid layout. It supports push, pull, and hidden features as well.
|
||||
|
||||
Here's a quick example:
|
||||
```
|
||||
import { ResponsiveGrid, GridRow, GridCol } from 'common/components';
|
||||
...
|
||||
|
||||
<ResponsiveGrid>
|
||||
<GridRow>
|
||||
<GridCol sm={12] lg={4}>
|
||||
// content
|
||||
</GridCol>
|
||||
<GridCol sm={12] lg={4}>
|
||||
// content
|
||||
</GridCol>
|
||||
<GridCol hiddenMdDown lg={4}>
|
||||
// content
|
||||
</GridCol>
|
||||
</GridRow>
|
||||
</ResponsiveGrid>
|
||||
```
|
||||
|
||||
The above example is equivalent to the following markup:
|
||||
```
|
||||
<div class="ms-grid">
|
||||
<div class="ms-grid-row">
|
||||
<div class="ms-grid-col ms-sm12 ms-lg4">
|
||||
// content
|
||||
</div>
|
||||
<div class="ms-grid-col ms-sm12 ms-lg4">
|
||||
// content
|
||||
</div>
|
||||
<div class="ms-grid-col ms-sm12 ms-lg4 ms-hiddenMdDown">
|
||||
// content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The `sm` prop can be ommitted if desired to shorten the code further. It will default to 12 columns if not specified.
|
||||
|
||||
## User Picker
|
||||
The `UserPicker` component is a wrapper around the Fluent UI People Picker component that integrates with the [Directory service](./services.md#directory-service) to supply people and group search results (does not require Graph permissions). It accepts and returns the selected users using our unified `User` class, which is used across components, services, and entity models.
|
||||
|
||||
```
|
||||
import { User } from 'common';
|
||||
import { UserPicker } from 'common/components';
|
||||
|
||||
...
|
||||
|
||||
const contacts: User[] = ...;
|
||||
const onContactsChanged = (newContacts: User[]) => { ... };
|
||||
|
||||
<UserPicker label="Contacts" users={contacts} onChanged={onContactsChanged} />
|
||||
```
|
||||
|
||||
There are additional configuration options for UserPicker:
|
||||
Property|Type|Description
|
||||
---|---|---
|
||||
display|UserPickerDisplayOption|Normal, List, or Compact, corresponds to the Fluent UI People Picker flavors
|
||||
restrictPrincipalType|PnPjs PrincipalType|Allow picking only users, only groups, or both users and groups
|
||||
restrictToGroupMembers|SharePointGroup|Allow picking only users that are a member of the specified SharePoint group. (Use the Directory service to get the group)
|
||||
|
||||
## Wizard
|
||||
The `Wizard` component supports a multi-screen paged experience that may include a start screen, as many intermediate pages as needed to inform the user or collect user input, and a final success screen, with the ability to run any asynchronous operations before getting to the success screen. It is especially useful as a first-run experience if the user is required to enter some configuration settings or the schema needs to be provisioned before the app can be used.
|
||||
|
||||
Check out the [Configuration Wizard](../src/components/setup/ConfigurationWizard.tsx) first-run experience implemented for the Rhythm of Business Calendar app.
|
||||
|
||||
## Asynchronous Data
|
||||
Any asynchronous data has various states that are tracked by the `AsyncData` class, such as loading, loaded, saving, and error. The `AsyncDataComponent` is the corresponding UI component that provides the boundary between the asynchronous operations and the actual data that child components can render. It does not provide any UI except to display a spinner when data is loading or saving, and to display an error message if the async operation unexpectedly fails.
|
||||
|
||||
```
|
||||
const { refinersAsync } = useRefinersService(); // refinersAsync is an AsyncData<readonly Refiner[]>
|
||||
|
||||
...
|
||||
|
||||
<AsyncDataComponent dataAsync={refinersAsync}>
|
||||
{(refiners: readonly Refiner[]) =>
|
||||
<ListOfRefiners refiners={refiners} />
|
||||
}
|
||||
</AsyncDataComponent>
|
||||
```
|
||||
|
||||
Usually, we retrieve async data objects from a service, which we can then pass directly to the `AsyncDataComponent`. When it renders, it will either display a spinner if the async data is in a loading/saving state, an error message if the async operation unexpectedly failed, or it will execute and display the results of the child render function.
|
||||
|
||||
## Panels and Dialogs
|
||||
A handful of specialized panel and dialog components are available depending on the scenario, but all are intented to support basic view-edit-save-or-discard flows to significantly reduce error-prone plumbing code.
|
||||
|
||||
Component|Data Type|UI|Comments
|
||||
---|---|---|---
|
||||
DataPanelBase|any object|Panel|A panel that displays and/or edits any kind of custom data
|
||||
DataDialogBase|any object|Dialog|A dialog that displays and/or edits any kind of custom data
|
||||
DataComponentBase|any object|(none)|A component that displays and/or edits any kind of custom data
|
||||
EntityPanelBase|Entity|Panel|A panel that displays and/or edits an Entity
|
||||
EntityDialogBase|Entity|Dialog|A dialog that displays and/or edits an Entity
|
||||
EntityComponentBase|Entity|(none)|A component that displays and/or edits an Entity
|
||||
|
||||
The panel components enhance the Fluent UI Panel component, and the dialog components enhance the Fluent UI Dialog component. The 'component' components provide no specific UI and can be used to embed a view-edit-save-or-discard flow into a screen.
|
||||
|
||||
Panels can be customized to have a simple or complex header with an optional toolbar, close button, simple title text or a complex overview of the data.
|
||||
|
||||
All of these components have three modes: view, edit, and readonly. When a panel/dialog is opened passing in an entity or some custom data object, you specify the mode by which function you call to open the panel/dialog, and if it is opened in readonly mode then it will not allow the UI to switch to edit mode. Each mode has corresponding functions that you will override to render UI elements for the toolbar, header, content, and footer. All functions are optional, and there are various fallbacks so that not every function must be overriden if the content is the same across all modes. For example, if the header UI will be the same in both view and edit modes, then only the `renderDisplayHeader` function needs to be implemented -- it will be automatically called to render the header UI for both view and edit modes (as well as the readonly mode if you intend to use that mode).
|
||||
|
||||
These components also have support for running validation rules before saving, as well as a built-in confirmation dialog that is displayed if the user is trying to close the dialog/panel but they have made changes to the data.
|
||||
|
||||
The entity version of these components adds additional automatic functionality specific for working with an Entity object. The validation logic directly calls the `valid()` function on the entity, but can be overriden if needed. When entering edit mode with an entity, the `snapshot()` function is automatically called on the entity, when changes are successfully persisted the `immortalize()` function is automatically called, and when dismissing the panel/dialog the `revert()` function is automatically called. Also when in edit mode, before the dialog/panel will dismiss, the `hasChanges()` function is called on the entity to determine if the user has made any changes while the dialog/panel was open in edit mode. See [Entity - Change tracking](./entities.md#change-tracking).
|
||||
|
||||
When the user wants to save their changes, and after the component has determined the changes are valid, the `persistChangesCore()` async function is called, which you should override to perform any persistance logic, such as calling a service to persist the entity to a SharePoint list. A spinner is automatically displayed over top of the contents of the panel/dialog while the persistChangesCore function is executing. If an unexpected error occurs, the panel/dialog also has built-in functionality for catching and displaying an error message bar to the user.
|
||||
|
||||
Examples of panels and dialogs can be referrenced in the Rhythm of Business Calendar app:
|
||||
* [RefinerPanel](../src/components/refiners/RefinerPanel.tsx)
|
||||
* [SettingsPanel](../src/components/settings/SettingsPanel.tsx)
|
||||
* [ApproversPanel](../src/components/approvals/ApproversPanel.tsx)
|
||||
* [EventPanel](../src/components/events/EventPanel.tsx)
|
||||
* [ApprovalDialog](../src/components/approvals/ApprovalDialog.tsx)
|
||||
|
||||
## Localize
|
||||
The `Localize` component enables scenarios where you want to use input controls in the middle of a sentance where the inputs can change location within the text depending on the language. It works by specifying tokens in the localized text string that the component then replaces with input controls that you specify for each token. Tokens placed in {} brackets.
|
||||
|
||||
As an example, suppose there is a localized string named **EndAfterNOccurences** defined as follows with one token named **count**:
|
||||
`EndAfterNOccurences: "End after {count} occurence(s)"`
|
||||
|
||||
And we want the UI to look like the following:
|
||||
![example of the Localize component in action](./assets/component-localize.png)
|
||||
|
||||
We can use the Localize component like this, rendering our custom `NumberTextField` component in place of the {count} token in the string:
|
||||
```
|
||||
<Localize
|
||||
phrase={strings.EndAfterNOccurences}
|
||||
components={{
|
||||
count: <NumberTextField {...text field props} />
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## SharePoint App
|
||||
The `SharePointApp` component handles basic plumbing that all apps will need. It handles initializing the services manager, displaying a customizable shimmer while services are initialized, setting up support for theming, initializing PnPjs for SharePoint and Graph, and ensuring the Fluent UI icons and styles are available.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```
|
||||
const AppServiceDescriptors = [
|
||||
...
|
||||
DirectoryServiceDescriptor,
|
||||
SharePointServiceDescriptor,
|
||||
LiveUpdateServiceDescriptor,
|
||||
ConfigurationServiceDescriptor,
|
||||
...
|
||||
];
|
||||
|
||||
...
|
||||
|
||||
class RhythmOfBusinessCalendarApp extends Component<IProps> {
|
||||
|
||||
...
|
||||
|
||||
public render(): ReactElement<IProps> {
|
||||
const { webpart } = this.props;
|
||||
|
||||
return (
|
||||
<SharePointApp
|
||||
appName="RhythmOfBusinessCalendar"
|
||||
companyName="Contoso"
|
||||
spfxComponent={webpart}
|
||||
serviceDescriptors={AppServiceDescriptors}
|
||||
shimmerElements={<MyCustomLoadingShimmer />}
|
||||
>
|
||||
// root UI component(s) for this app rendered here
|
||||
</SharePointApp>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Take a look at the [/src/apps/RhythmOfBusinessCalendarApp.tsx](../src/apps/RhythmOfBusinessCalendarApp.tsx) file for the full code to setup an app component your app.
|
||||
|
||||
This helps to greatly simplify and reduce the amount of code that is normally added to the SPFx web part file. You can see the difference for yourself here in [RhythmOfBusinessCalendarWebPart.tsx](../src/webparts/rhythmOfBusinessCalendar/RhythmOfBusinessCalendarWebPart.tsx).
|
||||
|
||||
## Other Components
|
||||
More components can be found in the [/src/common/components](../src/common/components/) folder.
|
|
@ -1,3 +1,358 @@
|
|||
# Entities
|
||||
|
||||
Coming soon
|
||||
Entities are the implementation of a rich domain model for your application, inspired by the Domain-Driven Design approach to software development. Entities live in the [model](../src/model/) folder in the solution.
|
||||
|
||||
All entities have the following features:
|
||||
* Change tracking (hasChanges, isNew, isDeleted, allowGhosting)
|
||||
* States (current, snapshot, and previous)
|
||||
* Relationships (one-to-many and many-to-many)
|
||||
* Text search
|
||||
* Validation
|
||||
* Filtering and sorting functions
|
||||
|
||||
All entities derive from the `Entity` class in `common`.
|
||||
|
||||
SharePoint list item entities have the following additional functionality:
|
||||
* Title
|
||||
* Author/Editor user
|
||||
* Created/Modified timestamp
|
||||
|
||||
SharePoint list item entities derive from the `ListItemEntity` class found in `common/sharepoint`.
|
||||
|
||||
## Defining an entity
|
||||
The essential parts of an entity are the class that derives from `Entity`, a state interface, and the constructor.
|
||||
|
||||
Here is an example of a SharePoint list item entity representing an item in a list called Categories. Note that the Title field is already defined in the `ListItemEntity` base class.
|
||||
|
||||
```
|
||||
import { User } from 'common';
|
||||
import { ListItemEntity } from 'common/sharepoint';
|
||||
|
||||
interface IState {
|
||||
int order;
|
||||
User[] owners;
|
||||
// ... any other properties - these typically correspond with fields on the SharePoint list
|
||||
}
|
||||
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
// The constructor for a list item entity must specify these parameters
|
||||
constructor(author?: User, editor?: User, created?: Moment, modified?: Moment, id?: number, uniqueId?: Guid, etag?: number) {
|
||||
super(author, editor, created, modified, id, uniqueId, etag);
|
||||
|
||||
// Each property in IState needs to be initialized here
|
||||
// If there's no other default value for your domain, then good options are 0 for numbers,
|
||||
// empty string ('') for strings, and an empty array ([]) for array types.
|
||||
this.state.order = 0;
|
||||
this.state.owners = [];
|
||||
}
|
||||
|
||||
// Define a getter/setter for each field
|
||||
public get order(): number { return this.state.order; }
|
||||
public set order(val: number) { this.state.order = val; }
|
||||
|
||||
public get owners(): User[] { return this.state.owners; }
|
||||
public set owners(val: User[]) { this.state.owners = val; }
|
||||
}
|
||||
```
|
||||
|
||||
Entity state supports all primitive types (string, number, boolean), plus Set, Map, our User class, Moment and Duration, Guid (from '@microsoft/sp-core-library'), custom objects, and arrays of any of the preceeding types.
|
||||
|
||||
If you want store an immutable object in state [example](../src/model/EventModerationStatus.ts), make sure that the immutable class implements a clone() method that simply returns itself:
|
||||
```
|
||||
public clone(): this {
|
||||
return this;
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
The `valid()` function runs each of the rules on the entity and returns true only if all rules succeed.
|
||||
|
||||
Let's extend the previous example to define validations for the `owners` field. This will indicate that the user must specify at least one owner and at most 5 owners for the category.
|
||||
|
||||
```
|
||||
import { User, ValidationRule, RequiredValidationRule, MaxItemsValidationRule } from 'common';
|
||||
|
||||
...
|
||||
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
// Define a custom property that specifies the validation rules for the owners field.
|
||||
// This is useful so that these rules can be easily passed to the Validation component on an edit screen.
|
||||
public static readonly OwnersValidations = [
|
||||
new RequiredValidationRule<Category>(e => e.owners),
|
||||
new MaxItemsValidationRule<Category>(e => e.owners, 5)
|
||||
];
|
||||
|
||||
...
|
||||
|
||||
// Override the validationRules function to return the validations for all of this entity's fields
|
||||
protected validationRules(): ValidationRule<Category>[] {
|
||||
return [
|
||||
...Category.OwnersValidations,
|
||||
// other validation rules
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The common library includes the following validation rules ready to use:
|
||||
Rule|Description
|
||||
---|---
|
||||
RequiredValidationRule|The field cannot be undefined/null, 0, an empty string, or a zero-length array
|
||||
MinValueValidationRule|If the number field has a value, it must have a value greater than or equal to the provided min value
|
||||
MaxValueValidationRule|If the number field has a value, it must have a value less than or equal to the provided max value
|
||||
RangeValueValidationRule|Combines the Min/Max rules into a single range rule
|
||||
MaxLengthValidationRule|The string value of the field must be less than or equal to the provided max value
|
||||
MaxItemsValidationRule|The number of items in an array field must be less than or equal to the provided max value
|
||||
UrlValidationRule|If the string field has a value, it must be a valid URL format
|
||||
EmailValidationRule|If the string field has a value, it must be a valid e-mail address format
|
||||
|
||||
To define your own custom validation rule, simply derive a class from `ValidationRule`. Some examples can be found in [Validations.ts](../src/model/Validations.ts).
|
||||
|
||||
## Display name property
|
||||
The `displayName` property is intended to be a general text string representation of the entity that can be rendered in the UI if desired. The property is abstract on the `Entity` base class, however the `ListItemEntity` base class overrides it to simply return the value of the `title` property (the built-in property that represents the Title field in a SharePoint list).
|
||||
|
||||
You may override it to return whatever you would like. For example:
|
||||
|
||||
```
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
...
|
||||
|
||||
// This might return a string similar to "1 - Marketing (2 owners)"
|
||||
public get displayName(): string {
|
||||
return [
|
||||
this.order && `${this.order} - `,
|
||||
this.title,
|
||||
`(${this.owners.length} owners)`
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Text search
|
||||
```
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
...
|
||||
|
||||
protected buildSearchHelperStrings(): string[] {
|
||||
return [
|
||||
...this.owners.map(owner => owner.title)
|
||||
];
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Filter and sort functions
|
||||
The convention is to define useful filtering and sorting functions as static members of the entity class.
|
||||
|
||||
An example of defining sorting functions for ordering the categories:
|
||||
```
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
public static readonly OrderAscComparer = (a: Category, b: Category) => a.order - b.order;
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
This can be utilized elsewhere in the app as follows:
|
||||
```
|
||||
const categories: Cateogory[] = ...
|
||||
categories.sort(Category.OrderAscComparer);
|
||||
```
|
||||
|
||||
An example of defining a filter function:
|
||||
```
|
||||
import { isEmpty } from 'lodash';
|
||||
...
|
||||
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
public static readonly HasOwnersFilter = ({ owners }: Category): boolean => !isEmpty(owners);
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The filter can be utilized as follows:
|
||||
```
|
||||
const categories: Cateogory[] = ...
|
||||
const categoriesWithOwners = categories.filter(Category.HasOwnersFilter);
|
||||
```
|
||||
|
||||
The `multifilter` and `aggregateFilter` utility functions from `'common'` can be used to apply multiple filters. The `multisort` and `aggregateComparer` utility functions from `'common'` can be used to achieve multi-column sorting.
|
||||
|
||||
## Change tracking
|
||||
Entities support change tracking for their fields. Imagine rendering an edit screen for an entity, for example editing an event on a calendar, and the user can make changes to the fields on the screen. When the user clicks the save button, we want the ability to know if the entity has been changed to know if we need to send the updated data back to the server. We also want to allow the user to close or cancel out of the edit screen, and if there are changes to the data we would like to pop up a confirmation dialog to be sure they want to discard those changes. If the user chooses to discard the changes, we want to be able to revert the state of the entity back to what it was before the user opened the edit screen.
|
||||
|
||||
This scenario is facilitated by three functions on the Entity class: `snapshot()`, `revert()`, and `immortalize()`.
|
||||
|
||||
Snapshot takes a copy of the entity's current state and stores that copy in the 'snapshot state'. The [data/entity panel/dialog/component components](./components.md) automatically call `snapshot()` when you invoked their `edit()` function and pass in an entity to edit.
|
||||
|
||||
While the entity has a snapshot, the properties of the entity can be updated, for instance in response to the user interacting with controls on the screen. The `hasChanges()` function can be called to determine if there are any changes between the current state and the snapshot state.
|
||||
|
||||
To discard the current set of changes, call `revert()` which will overwrite the current state using the snapshot state copy. To keep the current set of changes and reset change tracking, call `immortalize()` which will simply erase the snapshot state copy. These functions are called automatically if utilizing [data/entity panel/dialog/component components](./components.md).
|
||||
|
||||
## New and ghostable
|
||||
An entity is considered 'new' when it does not yet have an ID. For SharePoint items this means an ID that is a number greater than 0. The `isNew` property on the entity indicates this state.
|
||||
|
||||
In some situations, you may want to have an entity where new instances will only be persisted if the user makes explicit changes to any of it's properties. This is accomplished by overriding the `allowGhosting` property and returning true:
|
||||
```
|
||||
public get allowGhosting(): boolean {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
By default, when an entity is new (`isNew === true`) it will also report that it has changes (`hasChanges() === true`), however when ghosting is enabled `hasChanges()` will return false unless some value in the entity's current state is different than the snapshot state.
|
||||
|
||||
## Delete and soft delete
|
||||
Entities are marked for deletion by calling their `delete()` method. The `isDeleted` property indicates if an entity is marked for deletion. In the case of a SharePoint `ListItemEntity`, when the entity is persisted (saved) to SharePoint, the actual list item is deleted from the list.
|
||||
|
||||
If instead the desired behavior is to keep the item in the back-end storage and simply mark it as deleted ('soft delete' or 'archive'), then override the softDeleteSupported function in the entity class:
|
||||
```
|
||||
public get softDeleteSupported(): boolean {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
You must manage the deleted status of the entity when loading/persisting using a field in the back-end storage when choosing soft delete. For example, provisioning a Yes/No field named "Archived" on the SharePoint list. When persisting, use the `isDeleted` property to set the value of the "Archived" field, and when loading read the value of the "Archived" field and call `delete()` as appropriate.
|
||||
|
||||
## Relationships
|
||||
Entities also support one-to-many and many-to-many relationships through the use of navigation properties. The relationship classes handle all of the plumbing on both sides of the relationship so it is simple to set the parent on a child entity or add a child to a parent's collection. Relationships may also participate in change tracking through snapshot/revert/immortalize, so it easy to discard changes to all related entities from an edit screen.
|
||||
|
||||
### Defining a one-to-many relationship
|
||||
Let's say we also have a Meeting entity and want to create a relationship with the Category entity. Each meeting has a category, and each category can be linked to zero, one or more meetings. In your SharePoint list schema, you likely have a list for Categories and a list for Meetings, with the Meetings list having a lookup column that references the Categories list.
|
||||
|
||||
In the Meeting entity we would define the relationship like this:
|
||||
```
|
||||
import { IManyToOneRelationship, ManyToOneRelationship } from 'common';
|
||||
import { Category } from './Category';
|
||||
|
||||
interface IState {
|
||||
category: Category;
|
||||
}
|
||||
|
||||
export class Meeting extends ListItemEntity<IState> {
|
||||
constructor(author?: User, editor?: User, created?: Moment, modified?: Moment, id?: number, uniqueId?: Guid, etag?: number) {
|
||||
super(author, editor, created, modified, id, uniqueId, etag);
|
||||
|
||||
...
|
||||
|
||||
this.category = new ManyToOneRelationship<Meeting, Category>.create(this, 'meetings', 'category');
|
||||
}
|
||||
|
||||
public readonly category: IManyToOneRelationship<Category>;
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
This is the 'many-to-one' side of the relationship (many meetings to one category), so we use the `ManyToOneRelationship` class for the navigation property. The create static function takes three parameters: the current entity (this meeting), the name of the corresponding navigation property on the parent entity ('meetings'), and the name of the field in the state object that will store the parent object ('category' field in the IState interface). The fact that the field in state is named 'category' and the navigation property on the entity is also named 'category' is a useful convention but is not required.
|
||||
|
||||
In the Category entity we would define the relationship as follows:
|
||||
```
|
||||
import { IOneToManyRelationship, OneToManyRelationship } from 'common';
|
||||
import { Meeting } from './Meeting';
|
||||
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
constructor(author?: User, editor?: User, created?: Moment, modified?: Moment, id?: number, uniqueId?: Guid, etag?: number) {
|
||||
super(author, editor, created, modified, id, uniqueId, etag);
|
||||
|
||||
...
|
||||
|
||||
this.meetings = new OneToManyRelationship<Category, Meeting>.create(this, 'category');
|
||||
}
|
||||
|
||||
public readonly meetings: IOneToManyRelationship<Meeting>;
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
This is the 'one-to-many' side of the relationship (one category to many meetings), so we use the `OneToManyRelationship` class for the navigation property. The static create() function needs only two parameters: the current entity (this category), and the name of the corresponding navigation property on the child entity ('category'). There is no property to add to the entity state on the Category entity because the child collection of meetings is handled internally by the OneToManyRelationship class.
|
||||
|
||||
We can also specify sort options as an optional third parameter to the create() function for the collection of meetings on the Category entity by passing in an object with a comparer function and a flag indicating when to perform the sort (as soon as a child entity is added to the collection, or only when the collection is immortalized).
|
||||
|
||||
The following code demonstrates how to use these relationships:
|
||||
```
|
||||
const category = new Category();
|
||||
category.title = "Green";
|
||||
|
||||
const meeting1 = new Meeting();
|
||||
meeting1.category.set(category);
|
||||
|
||||
const meeting2 = new Meeting();
|
||||
categery.meetings.add(meeting2);
|
||||
|
||||
console.log(meeting1.category.get()?.title); // --> Green
|
||||
console.log(category.meetings.get().length); // --> 2
|
||||
|
||||
category.meetings.remove(meeting1);
|
||||
|
||||
console.log(meeting1.category.get()); // --> null
|
||||
console.log(category.meetings.get().length); // --> 1
|
||||
```
|
||||
|
||||
### Defining a many-to-many relationship
|
||||
Defining a many-to-many relationship is very similar to a one-to-many relationship. Let's change the previous example so that meetings may have more than one category.
|
||||
|
||||
On the Meeting entity:
|
||||
```
|
||||
import { IManyToManyRelationship, ManyToManyRelationship } from 'common';
|
||||
import { Category } from './Category';
|
||||
|
||||
export class Meeting extends ListItemEntity<IState> {
|
||||
constructor(author?: User, editor?: User, created?: Moment, modified?: Moment, id?: number, uniqueId?: Guid, etag?: number) {
|
||||
super(author, editor, created, modified, id, uniqueId, etag);
|
||||
|
||||
...
|
||||
|
||||
this.categories = new ManyToManyRelationship<Meeting, Category>.create(this, 'meetings');
|
||||
}
|
||||
|
||||
public readonly categories: IManyToManyRelationship<Category>;
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
And on the Category entity:
|
||||
```
|
||||
import { IManyToManyRelationship, ManyToManyRelationship } from 'common';
|
||||
import { Meeting } from './Meeting';
|
||||
|
||||
export class Category extends ListItemEntity<IState> {
|
||||
constructor(author?: User, editor?: User, created?: Moment, modified?: Moment, id?: number, uniqueId?: Guid, etag?: number) {
|
||||
super(author, editor, created, modified, id, uniqueId, etag);
|
||||
|
||||
...
|
||||
|
||||
this.meetings = new ManyToManyRelationship<Category, Meeting>.create(this, 'categories');
|
||||
}
|
||||
|
||||
public readonly meetings: IManyToManyRelationship<Meeting>;
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Notice that there is no state property on either entity because the relationship objects manage the collections internally on both sides of the relationship. Sorting options are also available for both sides of a many-to-many relationship.
|
||||
|
||||
### Bounded context
|
||||
Often times there is a very close relationship between entities, and changes the user makes to a set of related entities should be tracked together, committed together, or reverted together. For example, in the Rhythm of Business Calendar app, Refiner and RefinerValue are closely related. Each refiner has a set of values, and the edit screen for a refiner also allows the user to add/edit/delete/reorder the values, which should be persisted together or all reverted if the user discards changes.
|
||||
|
||||
This feature is achieved by specifying that the relationship is included when snapshot/revert/immortalize are called on the entity. We do that by adding a call to `includeInBoundedContext` after instantiating the relationship in the entity constructor:
|
||||
```
|
||||
export class Refiner extends ListItemEntity<IState> {
|
||||
constructor(author?: User, editor?: User, created?: Moment, modified?: Moment, id?: number, uniqueId?: Guid, etag?: number) {
|
||||
super(author, editor, created, modified, id, uniqueId, etag);
|
||||
|
||||
...
|
||||
|
||||
this.values = OneToManyRelationship.create<Refiner, RefinerValue>(this, 'refiner');
|
||||
this.includeInBoundedContext(this.values);
|
||||
}
|
||||
|
||||
public readonly values: IOneToManyRelationship<RefinerValue>;
|
||||
}
|
||||
```
|
||||
|
||||
Now any calls to `snapshot()`/`revert()`/`immortalize()` will cascade through the relationship to any related refiner values too. For instance, when we call `snapshot()` on a refiner, the refiner values relationship as well as any related refiner values will also have a snapshot taken. On the edit screen, the user may change the refiner's title, add a new refiner value, and change the title of an existing refiner value. Because a snapshot has been taken of the entire graph of related entities, we can now easily track which have changes and persist those if the user chooses to save their changes, or we can revert every change with one call to `revert()` on the refiner entity if the user wishes to discard their changes.
|
|
@ -1,3 +1,10 @@
|
|||
# Fast Load Caching
|
||||
<!--
|
||||
|
||||
Coming soon
|
||||
TODO
|
||||
|
||||
* Description of feature, note that it uses Cache API
|
||||
* To enable, simply set the flag on the loader
|
||||
* Tech details - uses existing loader methods to serialize/deserialize entities and maintain relationships
|
||||
|
||||
-->
|
|
@ -0,0 +1,17 @@
|
|||
# Potential future enhancements
|
||||
<!--
|
||||
|
||||
TODO
|
||||
|
||||
* Refactor data/entity panel/dialog/component to stop using inheritance
|
||||
* Simplify how we pass in localization strings for fields/commands (labels, aria labels, tooltips)
|
||||
* Transition to Luxon instead of MomentJS
|
||||
* Upgrade to PnPjs v3
|
||||
* Enhance entity text search - handle diacritics
|
||||
* Enhance the Live Relationship UI
|
||||
* Bounded context / boundary for snapshot/live update
|
||||
* Add support for content types to the Element Provisioner
|
||||
* Logging and telemetry patterns
|
||||
* Package command should support including teams manifest for specified env
|
||||
|
||||
-->
|
|
@ -1,3 +1,15 @@
|
|||
# Live Update
|
||||
<!--
|
||||
|
||||
Coming soon
|
||||
TODO
|
||||
|
||||
* Describe feature and philosophy
|
||||
* How to enable on the loader
|
||||
* How to use the LiveUpdate component
|
||||
* List and describe each live update control (LiveText, LiveDropdown, etc.)
|
||||
* Explain LiveRelationship
|
||||
* Note importance of wrapping screens in async data components and specifying async watchers on panels
|
||||
* Tech details - subscription to lists, query for updated data, change token
|
||||
* Tech details - begin live update / end live update
|
||||
|
||||
-->
|
|
@ -1,3 +1,105 @@
|
|||
# Schema
|
||||
Schema refers to the SharePoint elements that need to be provisioned and configured on the site for the app to function and be able to securely store data, such as lists, views and columns, and even custom security groups. The SPFx Solution Accelerator includes robust patterns and utilities for defining, provisioning, and upgrading the app's schema.
|
||||
|
||||
Coming soon
|
||||
The code describing the app's schema is located under the [/src/schema](../src/schema/) folder.
|
||||
|
||||
During startup, the app should check that the schema has been provisioned on the current site (usually this is simply a check to determine if the Configuration list for the app exists). If the schema has not been provisioned, then the app should display a setup screen/wizard for the user to configure any settings required to provision the application on the site. Then the app should use the `ElementProvisioner` class to ensure the schema is provisioned on the site.
|
||||
|
||||
See [/src/components/setup/ConfigurationWizard.tsx](../src/components/setup/ConfigurationWizard.tsx) for an example of a basic setup wizard that provisions the schema.
|
||||
|
||||
## Supported elements
|
||||
The schema can define, and the element provisioner can create and manage, the following types of SharePoint elements:
|
||||
* Lists - generic, events, document library, and picture library
|
||||
* Columns - Text, DateTime, Number, Yes/No, Choice, Lookup, User, Hyperlink, and more
|
||||
* Views
|
||||
* Site columns
|
||||
* Site security groups
|
||||
* Site custom permission levels
|
||||
|
||||
## Defining the root schema object
|
||||
A root schema is an object that implements `IElementDefinitions`. Here is a basic schema:
|
||||
```
|
||||
export const CurrentSchemaVersion: number = 1.0;
|
||||
|
||||
export const AppSchema = buildLiveSchema<IElementDefinitions>({
|
||||
version: CurrentSchemaVersion,
|
||||
lists: [
|
||||
EventsList,
|
||||
RefinersList,
|
||||
RefinerValuesList
|
||||
],
|
||||
upgrades: [
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
This defines the current version of the schema and specifies three lists to provision. It does not include any upgrade definitions since it is still on version 1. See [RhythmOfBusinessCalendarSchema.ts](../src/schema/RhythmOfBusinessCalendarSchema.ts) for a full example of a schema.
|
||||
|
||||
## Defining a list
|
||||
A list is an object that implements `IListDefinition`. It specifies the name of the list, the type of list, the columns, views, and permissions, as well as any dependencies on other lists (such as in the case of a lookup column).
|
||||
|
||||
A basic list looks like this:
|
||||
```
|
||||
export const RefinersList: IListDefinition = {
|
||||
title: 'Refiners',
|
||||
description: '',
|
||||
template: ListTemplateType.GenericList,
|
||||
fields: [
|
||||
Field_Order
|
||||
],
|
||||
views: [
|
||||
View_AllRefinerValues
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
Lists also support defining custom permissions (break role inheritence) with the built-in SP groups or custom security groups, creating default list items, moderation and versioning settings, ratings, and more.
|
||||
|
||||
## Defining a view
|
||||
A list view is an object that implements `IViewDefinition`. It specifies properties such as the name of the view, the row limit, whether it uses paging, which fields to show in the view, and can also include a CAML query for filtering, sorting, and or grouping.
|
||||
|
||||
A basic view looks like this:
|
||||
```
|
||||
const View_AllRefiners: IViewDefinition = {
|
||||
title: "All Refiners",
|
||||
fields: includeStandardViewFields(
|
||||
Field_Order
|
||||
)
|
||||
};
|
||||
```
|
||||
|
||||
The `includeStandardViewFields` utility function adds the "ID", "Title", "Author", "Editor", "Created", and "Modified" fields that are needed when loading data for a `ListItemEntity`.
|
||||
|
||||
## Defining a field
|
||||
A field is a column in a list and is defined by an object that implements one of the field definition-derived interfaces. Each field type in SharePoint has a corresponding interface that enables type-safe definitions.
|
||||
|
||||
A basic number field is defined like this:
|
||||
```
|
||||
const Field_Order: INumberFieldDefinition = {
|
||||
type: FieldType.Number,
|
||||
name: 'Order',
|
||||
min: 0
|
||||
};
|
||||
```
|
||||
|
||||
The `name` of the field is the internal name of the column in SharePoint. If you would like to have the column name appear differently when displayed to a user browsing the list, specify the `displayName` property too. `displayName` is optional and will use the value of `name` if not specified. Use the `required` boolean property to specify if the field is required. There are many other properties common to all field definitions, such as `indexed`, `hidden`, `readonly`, `hideInNewForm`, and more.
|
||||
|
||||
Field Type|Interface|Comments
|
||||
---|---|---
|
||||
Title|ITitleFieldDefinition|Used for updating properties of the built-in Title field, like changing the display name or max length
|
||||
Text|ITextFieldDefinition|Supports single- and mult-line, as well as various level of rich text
|
||||
Yes/No|IBooleanFieldDefinition|
|
||||
DateTime|IDateTimeFieldDefinition|Can specify the format as date and time, or date-only
|
||||
Number|INumberFieldDefinition|Can specify the min and max allowed values, as well as whether to display as a percentage
|
||||
Currency|ICurrencyFieldDefinition|Can specify the min and max allowed values, as well as the currency locale ID
|
||||
Hyperlink|IHyperlinkFieldDefinition|
|
||||
Picture|IPictureFieldDefinition|
|
||||
Users/Groups|IUserFieldDefinition|Can indicate if the field allows multiple users, as well as if it supports only users or both users and groups
|
||||
Choice|IChoiceFieldDefinition|Specify the list of choices as a string array, and the default value
|
||||
Lookup|ILookupFieldDefinition|Can specify that the lookup is multivalued, which list to reference, and which field to show
|
||||
Taxonomoy|ITaxonomyFieldDefinition|Can specify the term group name or 'sitecollection', the term set, the anchor term, whether or not to allow fill-in choices, and whether to allow muultiple values
|
||||
Calculated (text output)|ICalculatedTextFieldDefinition|
|
||||
Calculated (number output)|ICalculatedNumberFieldDefinition|
|
||||
Calculated (currency output)|ICalculatedCurrencyFieldDefinition|
|
||||
Calculated (date/time output)|ICalculatedDateTimeFieldDefinition|
|
||||
Calculated (yes/no output)|ICalculatedBooleanFieldDefinition|
|
||||
|
|
|
@ -1,3 +1,211 @@
|
|||
# Services
|
||||
The SPFx Solution accelerator includes its own services framework. The app specifies which services it needs using descriptors (objects that describe the services), the `ServiceManager` handles creating and initializing the specified services for the specific runtime environment (modern, classic, local, or test), and a React provider using the Context API enables components to consume services. Components indicate the specific services they need and those services are available through props or hooks.
|
||||
|
||||
Coming soon
|
||||
Originally, there were four different runtime environments (specified by the SPFx `EnvironmentType` class), but with classic SharePoint sites/pages being all but deprecated, the local workbench being deprecated and removed, and the best approach for testing being to create a custom mock service for specific tests, there is really only a need to have a concrete service implementation for modern SharePoint Online.
|
||||
|
||||
## Specifying services used by the app
|
||||
The app component specifies which services are needed for the entire app so that they can be created and initialized by the service manager. This is accomplished by adding the service descriptors to an array and passing that array to the `SharePointApp` component during render. See [src/apps/RhythmOfBusinessCalendarApp.tsx](../src/apps/RhythmOfBusinessCalendarApp.tsx).
|
||||
|
||||
```
|
||||
import { DeveloperServiceDescriptor, DirectoryServiceDescriptor, TimeZoneServiceDescriptor, SharePointServiceDescriptor, LiveUpdateServiceDescriptor, ConfigurationServiceDescriptor, EventsServiceDescriptor } from "services";
|
||||
|
||||
const AppServiceDescriptors = [
|
||||
DeveloperServiceDescriptor,
|
||||
TimeZoneServiceDescriptor,
|
||||
DirectoryServiceDescriptor,
|
||||
SharePointServiceDescriptor,
|
||||
LiveUpdateServiceDescriptor,
|
||||
ConfigurationServiceDescriptor,
|
||||
EventsServiceDescriptor
|
||||
];
|
||||
|
||||
class RhythmOfBusinessCalendarApp extends Component<IProps> {
|
||||
...
|
||||
|
||||
public render(): ReactElement<IProps> {
|
||||
...
|
||||
|
||||
return (
|
||||
<SharePointApp
|
||||
...
|
||||
serviceDescriptors={AppServiceDescriptors}
|
||||
...
|
||||
>
|
||||
...
|
||||
</SharePointApp>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Consuming services
|
||||
There are different methods for accessing services depending on the context.
|
||||
|
||||
### Function components - React hooks
|
||||
To access the current user from the Directory Service within a React function component using hooks:
|
||||
|
||||
```
|
||||
import React, { FC } from 'react';
|
||||
import { useDirectoryService } from 'services';
|
||||
|
||||
interface IProps {
|
||||
...
|
||||
}
|
||||
|
||||
export default const Widget: FC<IProps> = (props) => {
|
||||
const { currentUser } = useDirectoryService();
|
||||
|
||||
...
|
||||
|
||||
return <>{currentUser.title}</>;
|
||||
};
|
||||
```
|
||||
|
||||
### Class components
|
||||
To access the current user from the Directory Service from a React class component:
|
||||
|
||||
```
|
||||
import React, { Component } from 'react';
|
||||
import { withServices, ServicesProp, DirectoryService, DirectoryServiceProp } from 'services';
|
||||
|
||||
interface IOwnProps {
|
||||
...
|
||||
}
|
||||
type IProps = IOwnProps & ServicesProp<DirectoryServiceProp>;
|
||||
|
||||
interface IState {
|
||||
...
|
||||
}
|
||||
|
||||
class Widget extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
// accessing current user from the constructor
|
||||
const { [DirectoryService]: { currentUser } } = props.services;
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
private readonly _somePrivateFunction = () => {
|
||||
// accessing current user from a function
|
||||
const { [DirectoryService]: { currentUser } } = this.props.services;
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
// accessing current user in the render function
|
||||
const {
|
||||
services: { [DirectoryService]: { currentUser } }
|
||||
} = this.props;
|
||||
|
||||
return <>{currentUser.title}</>;
|
||||
}
|
||||
}
|
||||
|
||||
// this is where the services are injected in to the component's props
|
||||
export default withServices(Widget);
|
||||
```
|
||||
|
||||
This same approach also works for function components as an alternative to using hooks.
|
||||
|
||||
### From other services
|
||||
To access the Directory Service from another service:
|
||||
|
||||
```
|
||||
import { ServiceContext, DirectoryService, IDirectoryService, DirectoryServiceProp } from 'common/services';
|
||||
import { ICoffeeService } from './CoffeeServiceDescriptor';
|
||||
|
||||
export class CoffeeService implements ICoffeeService {
|
||||
private readonly _directory: IDirectoryService;
|
||||
|
||||
constructor({
|
||||
[DirectoryService]: directory
|
||||
}: ServiceContext<DirectoryServiceProp>) {
|
||||
this._directory = directory;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
const { currentUser } = this._directory;
|
||||
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Don't forget to add the Directory Service as a dependency on the Coffee Service descriptor:
|
||||
```
|
||||
export const CoffeeServiceDescriptor: IServiceDescriptor<typeof CoffeeService, ICoffeeService, CoffeeServiceProp> = {
|
||||
...
|
||||
dependencies: [..., DirectoryService, ...],
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
## Common Library Services
|
||||
The [Common Library (src/common)](../src/common/) provides several ready-to-use services for building apps.
|
||||
|
||||
### Developer Service
|
||||
The Developer Service is a mechanism for exposing custom helper scripts for developers while building your application. Any part of the application can access the Developer Service and register one or more functions that will only be available when the application is running in the SharePoint workbench or if 'isDev=true' is appended to the query string.
|
||||
|
||||
To use this service:
|
||||
1. Access the service and register a script:
|
||||
```
|
||||
const developer = useDeveloperService();
|
||||
developer.register({
|
||||
myHelpfulScripts: {
|
||||
doSomethingUseful: async () => {
|
||||
await ...
|
||||
console.log("I hope you find this useful.");
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
1. Run the application in the SharePoint workbench (or append 'isDev=true' to the query string)
|
||||
1. Open the DevTools console
|
||||
1. Execute the script:
|
||||
```
|
||||
dev.myHelpfulScripts.doSomethingUseful()
|
||||
```
|
||||
|
||||
The `dev` object is globally accessible.
|
||||
|
||||
There are many possible uses for developer scripts:
|
||||
* To quickly create useful sample data so that you can focus on building features rather than manually creating items in the app each time you need to recreate your lists or run the app on a new site. Check out [OnlineEventsService.ts](../src/services/events/OnlineEventsService.ts) for an example of this.
|
||||
* To create data to quickly set up scenarios for your testers, or for load testing the app
|
||||
* Work on code for migrating data from a legacy list/app
|
||||
* To toggle a feature on/off, or toggle a dev-only screen element
|
||||
* For example, say you are using React Router, you might build a small component that simply outputs the current route somewhere on the screen, and that component can register a dev script to easily toggle the display on and off to aid in debugging the routes.
|
||||
|
||||
### Directory Service
|
||||
The Directory Service provides access to users and SharePoint groups. It provides access to the current user and their permissions, plus functions to search/resolve users (which is utilized by the UserPicker component), find and manipulate SP groups, getting role definition IDs, and more.
|
||||
|
||||
Early on we recognized a need to unify the concept of a 'user' between SharePoint, PnPjs, MS Graph, etc., so we introduced the User class to the common library, which is our simple abstraction of a user, and is utilized everywhere from the UserPicker component, to entities, to the current user, SP group membership, and searching for users via the Directory Service, to loading and saving list items via the SharePoint service.
|
||||
|
||||
### Domain Isolation Service
|
||||
The Domain Isolation Service provides functions for converting to and from the isolated domain URL and the tenant's SharePoint domain URL, which is especially useful for implementing deep-linking capability in a domain-isolated solution.
|
||||
|
||||
### Live Update Service
|
||||
The Live Update Service provides high-level functionality for listening for changes to lists in order to support the Live Update feature. It is mainly consumed by other code in the common library.
|
||||
|
||||
### SharePoint Service
|
||||
The SharePoint service provides abstractions for loading and persisting entities as list items. It is mainly consumed by other code in the common library.
|
||||
|
||||
### Timezone Service
|
||||
The Timezone Service provides an abstraction SharePoint timezones and a mapping to their Moment.js equivalents. This service is especially useful in applications that need to read and write date and time values to SharePoint lists.
|
||||
|
||||
## Defining a Custom Service
|
||||
Please refer to the [Configuration service](../src/services/configuration) and [Events service](../src/services/events) for examples of defining a custom service, including creating the descriptor file for defining the service interface, symbol, and React service prop/hook, creating the implementation file and utilizing the SharePoint service, AsyncData, PagedViewLoader, and ListItemEntity for loading and persisting data to SharePoint lists, as well as examples for implementing the track/persist pattern.
|
||||
|
||||
<!--
|
||||
TODO
|
||||
|
||||
* describe how to create a custom service
|
||||
* descriptor file and contents
|
||||
* index file for exports
|
||||
* implementation
|
||||
* ctor pattern
|
||||
* init pattern
|
||||
* async pattern
|
||||
* track/persist pattern
|
||||
* loaders
|
||||
* refer to entities page for loading up relationships
|
||||
* how to load/persist various field types
|
||||
-->
|
|
@ -1,3 +1,24 @@
|
|||
# Solution Structure
|
||||
The prescribed folder structure helps to organize the application code in to layers with clear dependencies, that is similar to Onion Architecture.
|
||||
|
||||
Coming soon
|
||||
## \src Folders
|
||||
|
||||
* [apps](../src/apps/) - there is typically only one 'App' component defined for the app, which specifies the services the app uses, other initialization, and manages the setup experience, upgrade experience, and rendering of the root component
|
||||
* [common](../src/common/) - this is the "framework" part of the SPFx Solution Accelerator. It contains common code, components, services, and utilities that can be reused across many different applications. Code specific to the app you are building should go in one of the other root folders.
|
||||
* [common/components](../src/common/components/) - this folder contains reusable components. See the [Components](./components.md) page for more details.
|
||||
* [common/services](../src/common/services/) - this folder contains our services framework implementation plus various services ready to use in your apps. See the [Services](./services.md) page for more details.
|
||||
* [common/sharepoint](../src/common/sharepoint/) - this folder contains specialized code for interacting with SharePoint: provisioning fields, lists, and views, and primitives for querying and updating data. See the [Schema](./schema.md) page for details on provisioning.
|
||||
* [components](../src/components/) - this is the folder for all React components specific to the app
|
||||
* [model](../src/model/) - this folder is used for defining the entities (the domain model) and any other business logic for the app
|
||||
* [schema](../src/schema/) - if the app uses fields, lists, views, and/or security groups (essentially, anything that needs provisioning in SharePoint), this is the folder for defining these items
|
||||
* [services](../src/services/) - this folder is for defining app-specific services
|
||||
* [webparts](../src/webparts/) - this is the standard webparts folder in all SPFx solutions. We typically keep the code under this folder very brief and prefer to divide all the components and business logic among the other project folders described above. The web part does not contain any business logic or initialization code, rather it simply renders the App component.
|
||||
|
||||
There is an implicit direction for dependencies when using this folder structure:
|
||||
* webparts - depends on apps
|
||||
* apps - depends on common, services, and components
|
||||
* components - depends on common, services, and model
|
||||
* services - depends on common, model, and schema
|
||||
* schema - depends on common and model
|
||||
* common - no dependencies since this should be reserved for code that can be reused across projects
|
||||
* model - no dependencies
|
||||
|
|
|
@ -20,7 +20,7 @@ export class EventModerationStatus {
|
|||
) {
|
||||
}
|
||||
|
||||
public clone(): EventModerationStatus {
|
||||
public clone(): this {
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ import { IEmailProperties } from '@pnp/sp/sputilities';
|
|||
import { IMicrosoftTeams } from '@microsoft/sp-webpart-base';
|
||||
import { format } from '@fluentui/react';
|
||||
import { Color, Entity, humanizeFixedList, IAsyncData, multifilter, now, User } from 'common';
|
||||
import { ServiceContext, DeveloperService, DeveloperServiceProp, SharePointServiceProp, SharePointService, ISharePointService, TimeZoneServiceProp, TimeZoneService, ITimeZoneService, LiveUpdateServiceProp, LiveUpdateService, ILiveUpdateService, DirectoryService, DirectoryServiceProp, IDirectoryService, SpfxContext, TeamsJs } from 'common/services';
|
||||
import { ServiceContext, DeveloperService, DeveloperServiceProp, SharePointServiceProp, SharePointService, ISharePointService, TimeZoneServiceProp, TimeZoneService, ITimeZoneService, LiveUpdateServiceProp, LiveUpdateService, ILiveUpdateService, DirectoryService, DirectoryServiceProp, IDirectoryService, TeamsJs } from 'common/services';
|
||||
import { RoleType } from 'common/sharepoint';
|
||||
import { Approvers, Event, EventModerationStatus, humanizeDateRange, humanizeRecurrencePattern, ReadonlyEventMap, Refiner, RefinerValue } from 'model';
|
||||
import { ConfigurationService, IConfigurationService, ConfigurationServiceProp } from '../configuration';
|
||||
|
@ -18,7 +18,6 @@ import { Defaults } from './Defaults';
|
|||
import { AppName, ApprovalEmails as strings } from 'ComponentStrings';
|
||||
|
||||
export class OnlineEventsService implements IEventsService {
|
||||
private readonly _context: SpfxContext;
|
||||
private readonly _teams: IMicrosoftTeams;
|
||||
private readonly _timezones: ITimeZoneService;
|
||||
private readonly _liveUpdate: ILiveUpdateService;
|
||||
|
@ -32,7 +31,6 @@ export class OnlineEventsService implements IEventsService {
|
|||
private _approversLoader: ApproversLoader;
|
||||
|
||||
constructor({
|
||||
[SpfxContext]: context,
|
||||
[TeamsJs]: teams,
|
||||
[DeveloperService]: dev,
|
||||
[TimeZoneService]: timezones,
|
||||
|
@ -41,7 +39,6 @@ export class OnlineEventsService implements IEventsService {
|
|||
[SharePointService]: spo,
|
||||
[ConfigurationService]: configurations
|
||||
}: ServiceContext<DeveloperServiceProp & TimeZoneServiceProp & LiveUpdateServiceProp & DirectoryServiceProp & SharePointServiceProp & ConfigurationServiceProp>) {
|
||||
this._context = context;
|
||||
this._teams = teams;
|
||||
this._timezones = timezones;
|
||||
this._liveUpdate = liveUpdate;
|
||||
|
|
Loading…
Reference in New Issue