Added new sample web part using functional components, React Hooks, and data fetching using PnPJS.

This commit is contained in:
Bill Ayers 2019-06-14 17:14:50 +01:00
parent bfadbf23e8
commit 86fb04a51f
24 changed files with 18350 additions and 0 deletions

View File

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

View File

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

View File

@ -0,0 +1,12 @@
{
"@microsoft/generator-sharepoint": {
"version": "1.8.2",
"libraryName": "teams-tracker",
"libraryId": "f5a39f0e-c504-4969-8e47-1ed85d9f8a68",
"environment": "spo",
"packageManager": "npm",
"isCreatingSolution": true,
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,137 @@
# React Functional Component web part with data fetch
## Summary
This web part demonstrates building a React functional component that uses data from a remote service, in this case the Microsoft Graph, using the recently introduced React Hooks feature. The example web part renders a list of the user's Teams and, optionally, the channels in each Team.
![Screenshot](Screenshot.png "Screenshot - Teams Tracker web part")
## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/version-GA-green.svg)
## Applies to
* [SharePoint Framework](https:/dev.office.com/sharepoint)
* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
* [PnPJS library](https://github.com/pnp/pnpjs)
## Prerequisites
This sample was built with version 1.82 of the SharePoint Framework. It has been modified to use version 16.8 of the React framework (by default the version used is React 16.7). React 16.8 supports React Hooks which is required to support state management in a React functional component.
With future versions of SPFx it will be possible to use the built-in React version, which avoids the need to add a specific version of React to the bundle. This sample also uses the PnPJS library to retrieve Microsoft Graph data.
## Solution
Solution|Author(s)
--------|---------
react-functional-component-with-data-fetch | Bill Ayers
## Version history
Version|Date|Comments
-------|----|--------
1.0|June 14, 2019|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
* Clone this repository
* Move to /samples/react-functional-component-with-data-fetch folder
* At the command line run:
* `npm install`
* `gulp serve --nobrowser`
* Navigate to *https://mytenant.sharepoint.com/_layouts/15/workbench.aspx*
* Sign in to your account if needed
## Features
The purpose of this web part is to demonstrate building a React functional component that includes state and data fetched from a remote service. This is achieved using the recent React Hooks feature. The resulting code is cleaner and easier to follow than using a JavaScript/TypeScript class derived from React.Component. The example web part renders a list of the user's Teams and, if enabled, a list of the Teams channels for each Team with a link to the channel.
![Screenshot](ShowChannels.png "Screenshot - Teams Tracker web part with Teams channels displayed")
This is an extension of the approach used in the [React-Functional-Component](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-functional-component) and [React-Functional-Stateful-Component](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-functional-stateful-component) samples.
* Simplification
* Functional Component
* Fetching Data
* Team.tsx Component
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-functional-component-with-data-fetch" />
## TeamsTrackerWebPart.ts Simplification
A number of simplifications have been made to the TeamsTrackerWebPart.ts file compared to the Yeoman generator starter project. The use of an external string collection has been removed - they are simply hard coded into the file to make it clear how the property pane configuration works.
The properties interface is declared inline in TeamsTrackerWebPart.ts and the same properties are passed to the component as props. The property will then be available to the component through its **props** collection.
The onInit method of BaseClientSideWebPart is overriden to initialise the PnPJS graph object. The web part is then able to get access to the Microsoft Graph using the PnPJS library. The User.Read.All permission is implicitly provided.
## Functional Component with state
The TeamsTracker.tsx React Component is a React functional component. This simplifies the code structure to a simple JavaScript function with the name of the component, a single argument containing the React props, and a simple return of the component rendering. Because it is just a function, there is no need to worry about **this** or **that**, constructors, lifecycle events, etc. In this example we use state so this is provided by the React.useState hook. [React Hooks](https://reactjs.org/docs/hooks-intro.html) is a fairly new feature of React:
```
const initialTeamsList: MSGraph.Group[] = null;
const [teamsList, setTeamsList] = React.useState(initialTeamsList);
```
React.useState takes an initial value for the state variable, which we initialise to *null* but by means of a strongly typed const. This means that we will get type checking and intellisense for the state object. React.useState returns an array of two objects. The first is a variable containing the state value, and the second is a setter function for the value, and the convention is to use the array destructuring operator to unpack them into local constants. Whenever we need to use the current value of the teamsList we just use it as a normal variable, and wherever we need to change the value we call *setTeamsList(newValue)*.
## Fetching Data
If we were writing a React component class, we would need to use various lifecycle methods like componentDidMount and componentDidUpdate, etc. With functional components and React Hooks we use a React method called *useEffect*, which is designed to allow you to include code that enables your functional component to manage side-effects (hence the name). The code to call the Microsoft Graph is very simple:
```
React.useEffect(() => {
graph.me.joinedTeams.get().then(teams => { setTeamsList(teams); });
}, []);
```
We use the PnPJS library to get a list of Teams from the Microsoft Graph, and then use the setTeamsList method (which you may remember was the second element in the array returned by the React.useState function) to set the value of the state variable. Calling setTeamsList is very similar to calling *setState()* when doing things the 'old way'. It will result in the component being re-rendered to reflect the changes to the state variable.
You might have noticed that there is a second argument to React.useEffect, and we have passed into it an empty array. This array contains any variables that we depend on, for example a filter or other display option - if they change it will result in the useEffect callback function being called again. If we omit the second argument altogether then the graph call will happen whenever the component is rendered, and since the promise causes a re-rendering, the result would be a nasty infinite loop. By providing an empty array we ensure that the useEffect callback only gets called when the component first loads.
For convenience we have rendered the dynamic bit of the web part into a variable called *content*. We can show a UI Fabric spinner if the data isn't loaded, or a helpful message if the data returned is an empty array. Here is the TSX code that renders the list of Teams:
```
<ul>
{teamsList.map(team => (
<li key={team.id}>
<Team channelID={team.id} displayName={team.displayName} showChannels={props.showChannels} />
</li>
))}
</ul>
```
Notice that we just take the teamsList (our state variable) and use a *map* function to render each team into a series of &lt;li> elements. Each team is rendered using a &lt;Team> component which we have defined in Team.tsx.
## Team.tsx Component
The Teams.tsx component is only responsible for rendering an individual team, and the structure of the component follows a similar pattern to that of TeamTracker.tsx. Notice first that we use a different approach to using the props by using the object destructuring operator to unpack the individual values from the outset, which can sometimes make the subsequent code a little clearer:
```
export default function Team({ channelID, displayName, showChannels }) {
```
Again we use state to manage a list of channels and initialise it to an empty array. But in this case we have a property *showChannels* that is the flag set by the user in the property pane. If this is enabled we retrieve the channels from the Microsoft Graph using the PnPJS library, and if not we set it to an empty array. We need to explicitly reset the array in case the user enables and then subsequently disables the *showChannels* option. Finally, notice that we now need to include a dependency for the second argument of *useEffect* so that the framework knows to call the method again if the value of showChannels changes.
```
const [channelsList, setChannelsList] = React.useState([]);
React.useEffect(() => {
if (showChannels)
graph.teams.getById(channelID).channels.get().then(channels => { setChannelsList(channels); });
else
setChannelsList([]);
}, [showChannels]);
```
The rest of Team.tsx simply returns the rendering of the component with the name of the Team and a list of channels. If the *channelsList* is empty it will just render an empty &lt;ul>.
If this were a real application, rather than a demonstration, you would need to decide whether it was efficient to make multiple graph calls, or whether to batch the calls in some way, which would probably make the code a little more complicated. If you end up with a large hierarchy of nested components you might also use the *useContext* hook to manage data that you retrieve at a higher level, to be referenced in lower level components without having to pass everything down through props.
## Building and testing
In the react-functional-component directory, run **npm install** to resolve all the dependencies. Once this has completed you can run **gulp serve --nobrowser** to test the web part in the workbench of your tenant (*https://mytenant.sharepoint.com/_layouts/15/workbench.aspx*). You could run it in the local workbench, but the PnPJS promise will never return and so you will just see the loading spinner.

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -0,0 +1,18 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"teams-tracker-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/teamsTracker/TeamsTrackerWebPart.js",
"manifest": "./src/webparts/teamsTracker/TeamsTrackerWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"TeamsTrackerWebPartStrings": "lib/webparts/teamsTracker/loc/{locale}.js"
}
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "teams-tracker-client-side-solution",
"id": "f5a39f0e-c504-4969-8e47-1ed85d9f8a68",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false
},
"paths": {
"zippedPackage": "solution/teams-tracker.sppkg"
}
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
build.configureWebpack.mergeConfig({
additionalConfiguration: (config) => {
config.externals = config.externals.filter(name => !(["react", "react-dom"].includes(name)))
return config;
}
});
build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
{
"name": "teams-tracker",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.8.2",
"@microsoft/sp-lodash-subset": "1.8.2",
"@microsoft/sp-office-ui-fabric-core": "1.8.2",
"@microsoft/sp-property-pane": "1.8.2",
"@microsoft/sp-webpart-base": "1.8.2",
"@pnp/common": "^1.3.3",
"@pnp/graph": "^1.3.3",
"@pnp/logging": "^1.3.3",
"@pnp/odata": "^1.3.3",
"@types/es6-promise": "0.0.33",
"@types/webpack-env": "1.13.1",
"office-ui-fabric-react": "6.143.0",
"react": "^16.8.6",
"react-dom": "^16.8.6"
},
"resolutions": {
"@types/react": "16.7.22"
},
"devDependencies": {
"@microsoft/rush-stack-compiler-2.9": "0.7.7",
"@microsoft/rush-stack-compiler-3.3": "^0.2.15",
"@microsoft/sp-build-web": "1.8.2",
"@microsoft/sp-module-interfaces": "1.8.2",
"@microsoft/sp-tslint-rules": "1.8.2",
"@microsoft/sp-webpart-workbench": "1.8.2",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"@types/react": "^16.8.19",
"@types/react-dom": "^16.8.4",
"ajv": "~5.2.2",
"gulp": "~3.9.1",
"typescript": "^3.5.1"
}
}

View File

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

View File

@ -0,0 +1,29 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "07986dfd-316b-4868-9597-7431a682dd4c",
"alias": "TeamsTrackerWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart"],
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "Other" },
"title": { "default": "TeamsTracker" },
"description": { "default": "TeamsTracker sample React functional component with MS Graph data" },
"officeFabricIconFontName": "Page",
"properties": {
"title": "Teams Tracker",
"description": "You are a member of the following teams:",
"showChannels": false
}
}]
}

View File

@ -0,0 +1,57 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IPropertyPaneConfiguration, PropertyPaneTextField, PropertyPaneToggle } from '@microsoft/sp-property-pane';
import TeamsTracker from './components/TeamsTracker';
import { graph } from '@pnp/graph';
// tidy up props and property pane
export interface ITeamsTrackerWebPartProps {
title: string;
description: string;
showChannels: boolean;
}
export default class TeamsTrackerWebPart extends BaseClientSideWebPart<ITeamsTrackerWebPartProps> {
public onInit(): Promise<void> {
return super.onInit().then(() => {
graph.setup({ spfxContext: this.context });
});
}
public render(): void {
ReactDom.render(React.createElement(TeamsTracker, this.properties), this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: "Properties"
},
groups: [
{
groupName: "General",
groupFields: [
PropertyPaneTextField('title', { label: "Web part title" }),
PropertyPaneTextField('description', { label: "Description Text" }),
PropertyPaneToggle('showChannels', { label: "Show channels" })
]
}
]
}
]
};
}
}

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import { graph } from '@pnp/graph';
export default function Team({ channelID, displayName, showChannels }) {
const [channelsList, setChannelsList] = React.useState([]);
React.useEffect(() => {
if (showChannels)
graph.teams.getById(channelID).channels.get().then(channels => { setChannelsList(channels); });
else
setChannelsList([]);
}, [showChannels]);
return (
<div>
<h4>{displayName}</h4>
<ul>
{channelsList.map(channel => (
<li key={channel.id}>
<a href={channel.webUrl}>{channel.displayName}</a>
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,21 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.container {
max-width: 700px;
margin: 0px auto;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.row {
@include ms-Grid-row;
background-color: $ms-color-themeLight;
padding: 20px;
}
.column {
@include ms-Grid-col;
@include ms-lg10;
@include ms-xl8;
@include ms-xlPush2;
@include ms-lgPush1;
}

View File

@ -0,0 +1,50 @@
import * as React from 'react';
import styles from './TeamsTracker.module.scss';
import { escape } from '@microsoft/sp-lodash-subset';
import * as Fabric from 'office-ui-fabric-react';
import { graph } from '@pnp/graph';
import * as MSGraph from '@microsoft/microsoft-graph-types';
import { ITeamsTrackerWebPartProps } from '../TeamsTrackerWebPart';
import Team from './Team';
export default function TeamsTracker(props: ITeamsTrackerWebPartProps) {
// Use React Hooks to manage state - useState returns value and setter as array...
const initialTeamsList: MSGraph.Group[] = null;
const [teamsList, setTeamsList] = React.useState(initialTeamsList);
// Use React Hooks to manage lifecycle events like data fetching...
React.useEffect(() => {
graph.me.joinedTeams.get().then(teams => { setTeamsList(teams); });
}, []);
// create the content to be shown in the second column
var content = null;
if (teamsList === null) content = <Fabric.Spinner />;
else if (teamsList.length === 0) content = <div>You are not a member if any teams.</div>;
else content = (
<div>
<ul>
{teamsList.map(team => (
<li key={team.id}>
<Team channelID={team.id} displayName={team.displayName} showChannels={props.showChannels} />
</li>
))}
</ul>
</div>
);
return (
<div className={styles.container}>
<div className={styles.row}>
<div className={styles.column}>
<h2>{escape(props.title)}</h2>
<p>{escape(props.description)}</p>
</div>
<div className={styles.column}>
{content}
<br />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
// dummy locale file to keep gulp happy
define([], function() { return {}});

View File

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

View File

@ -0,0 +1,30 @@
{
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": false,
"no-construct": false,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}