SPFx webpart sample using Redux Async Actions and ImmutableJS (#262)

* added wp base

* started on adding redux

* removed immutable to start with

* initial code working (redux, immutable)

* get lists, basic add new list ready

* basic code using redux and immutable js ready

* comments

* image and readme

* readme update

* updated image

* appease the linter

* added comments

* changed folder name

* updated gif

* typo in layouts path

* link to blog and telemetry
This commit is contained in:
Vardhaman Deshpande 2017-07-21 18:55:53 +01:00 committed by Vesa Juvonen
parent 35fff34e69
commit db3a0653a2
35 changed files with 14555 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 @@
* text=auto

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,14 @@
# Folders
.vscode
coverage
node_modules
sharepoint
src
temp
# Files
*.csproj
.git*
.yo-rc.json
gulpfile.js
tsconfig.json

View File

@ -0,0 +1,8 @@
{
"@microsoft/generator-sharepoint": {
"version": "1.1.1",
"libraryName": "react-redux-restapi",
"libraryId": "b40fef70-090e-43b4-adc7-64b379287548",
"environment": "spo"
}
}

View File

@ -0,0 +1,52 @@
# SharePoint Framework webpart sample using React, Redux and ImmutableJS
## Summary
SharePoint Framework webpart which uses [Redux](http://redux.js.org/) to maintain a single state for the entire application and [ImmutableJS](https://facebook.github.io/immutable-js/) to create performant state trees.
Redux AJAX actions are used together with the SharePoint REST API to display lists in your site. You can also add a new list to the site from this webpart.
More details in my post here: [Using Redux Async Actions and ImmutableJS in SharePoint Framework](http://www.vrdmn.com/2017/07/using-redux-async-actions-and.html)
![](https://raw.githubusercontent.com/vman/sp-dev-fx-webparts/master/samples/react-redux-async-immutablejs/assets/react-redux-immutable.gif)
## 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)
## Solution
Solution|Author(s)
--------|---------
React-Redux-RESTAPI | Vardhaman Deshpande [@vrdmn](https://twitter.com/vrdmn)
## Version history
Version|Date|Comments
-------|----|--------
1.0|July 11, 2017|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
- Clone this repository
- in the command line run:
- `npm install`
- `gulp serve`
- Open the SharePoint Online version of the workbench: /_layouts/15/workbench.aspx
## Why Redux and ImmutableJS
Every [Redux](http://redux.js.org/) action creates a copy of the state, changes the required properties in the copy and then returns the copy as a new state. This prevents bugs where the state is changed unknowingly.
On the other hand, this can also be performance intensive as for changing only a single element, we have to copy the entire state tree in memory. Fortunately, [ImmutableJS](https://facebook.github.io/immutable-js/) comes to the rescue here.
Using ImmutableJS, we can create new state trees in memory without duplicating the elements which are unchanged. When you create a new state object using ImmutableJS, the new object still points to the previous memory locations of unchanged elements. Only the properties which are changed are allocated new memory locations.
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-redux-async-immutablejs" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

View File

@ -0,0 +1,13 @@
{
"entries": [
{
"entry": "./lib/webparts/demo/DemoWebPart.js",
"manifest": "./src/webparts/demo/DemoWebPart.manifest.json",
"outputPath": "./dist/demo.bundle.js"
}
],
"externals": {},
"localizedResources": {
"demoStrings": "webparts/demo/loc/{locale}.js"
}
}

View File

@ -0,0 +1,3 @@
{
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,6 @@
{
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "react-redux-restapi",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,10 @@
{
"solution": {
"name": "react-redux-restapi-client-side-solution",
"id": "b40fef70-090e-43b4-adc7-64b379287548",
"version": "1.0.0.0"
},
"paths": {
"zippedPackage": "solution/react-redux-restapi.sppkg"
}
}

View File

@ -0,0 +1,9 @@
{
"port": 4321,
"initialPage": "https://localhost:5432/workbench",
"https": true,
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

View File

@ -0,0 +1,45 @@
{
// Display errors as warnings
"displayAsWarning": true,
// The TSLint task may have been configured with several custom lint rules
// before this config file is read (for example lint rules from the tslint-microsoft-contrib
// project). If true, this flag will deactivate any of these rules.
"removeExistingRules": true,
// When true, the TSLint task is configured with some default TSLint "rules.":
"useDefaultConfigAsBase": false,
// Since removeExistingRules=true and useDefaultConfigAsBase=false, there will be no lint rules
// which are active, other than the list of rules below.
"lintConfig": {
// Opt-in to Lint rules which help to eliminate bugs in JavaScript
"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-case": true,
"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-unused-imports": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"valid-typeof": true,
"variable-name": false,
"whitespace": false
}
}
}

View File

@ -0,0 +1,3 @@
{
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,6 @@
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
{
"name": "react-redux-restapi",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=0.10.0"
},
"dependencies": {
"@microsoft/sp-core-library": "~1.1.0",
"@microsoft/sp-webpart-base": "~1.1.1",
"@types/react": "0.14.46",
"@types/react-addons-shallow-compare": "0.14.17",
"@types/react-addons-test-utils": "0.14.15",
"@types/react-addons-update": "0.14.14",
"@types/react-dom": "0.14.18",
"@types/webpack-env": ">=1.12.1 <1.14.0",
"immutable": "^3.8.1",
"react": "15.4.2",
"react-dom": "15.4.2",
"react-redux": "^5.0.5",
"redux": "^3.7.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.2.0"
},
"devDependencies": {
"@microsoft/sp-build-web": "~1.1.0",
"@microsoft/sp-module-interfaces": "~1.1.0",
"@microsoft/sp-webpart-workbench": "~1.1.0",
"@types/chai": ">=3.4.34 <3.6.0",
"@types/mocha": ">=2.2.33 <2.6.0",
"@types/react-redux": "^4.4.45",
"@types/redux-logger": "^3.0.0",
"gulp": "~3.9.1"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
}
}

View File

@ -0,0 +1,26 @@
{
"$schema": "../../../node_modules/@microsoft/sp-module-interfaces/lib/manifestSchemas/jsonSchemas/clientSideComponentManifestSchema.json",
"id": "096e9abd-b73c-4b73-8292-621bf1257402",
"alias": "DemoWebPart",
"componentType": "WebPart",
"version": "*", // The "*" signifies that the version should be taken from the package.json
"manifestVersion": 2,
/**
* This property should only be set to true if it is certain that the webpart does not
* allow arbitrary scripts to be called
*/
"safeWithCustomScriptDisabled": false,
"preconfiguredEntries": [{
"groupId": "096e9abd-b73c-4b73-8292-621bf1257402",
"group": { "default": "Under Development" },
"title": { "default": "Redux ImmutableJS Demo" },
"description": { "default": "Webpart to Demo Redux and ImmutableJS" },
"officeFabricIconFontName": "Page",
"properties": {
"description": "Demo"
}
}]
}

View File

@ -0,0 +1,60 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneTextField
} from '@microsoft/sp-webpart-base';
import * as strings from 'demoStrings';
import Demo from './components/Demo';
import { IDemoWebPartProps } from './IDemoWebPartProps';
import configureStore from './store/configureStore';
import { Provider } from 'react-redux';
const store = configureStore();
export default class DemoWebPart extends BaseClientSideWebPart<IDemoWebPartProps> {
public render(): void {
const provider: React.ReactElement<Provider> = React.createElement(typeof Provider, null, React.createElement(
Demo,
{
store: store, //Pass store explicitly to Demo as a prop
description: this.properties.description,
spHttpClient: this.context.spHttpClient,
currentWebUrl: this.context.pageContext.web.serverRelativeUrl
}));
ReactDom.render(provider, this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneTextField('description', {
label: strings.DescriptionFieldLabel
})
]
}
]
}
]
};
}
}

View File

@ -0,0 +1,3 @@
export interface IDemoWebPartProps {
description: string;
}

View File

@ -0,0 +1,18 @@
export enum ActionTypes {
ADD_LIST_REQUEST,// = "ADD_LIST_REQUEST" //String enums in TypeScript 2.4+
ADD_LIST_SUCCESS,
ADD_LIST_ERROR,
GET_LISTS_REQUEST,
GET_LISTS_SUCCESS,
GET_LISTS_ERROR,
UPDATE_TITLE,
}
export type Action =
{ type: ActionTypes.ADD_LIST_REQUEST } |
{ type: ActionTypes.ADD_LIST_SUCCESS, payload: string } |
{ type: ActionTypes.ADD_LIST_ERROR, payload: string } |
{ type: ActionTypes.GET_LISTS_REQUEST } |
{ type: ActionTypes.GET_LISTS_SUCCESS, payload: string[] } |
{ type: ActionTypes.GET_LISTS_ERROR, payload: string } |
{ type: ActionTypes.UPDATE_TITLE, payload: string };

View File

@ -0,0 +1,80 @@
import { ActionTypes, Action } from './actionTypes';
import { SPHttpClient, ISPHttpClientOptions, SPHttpClientResponse } from '@microsoft/sp-http';
import { IODataList } from '@microsoft/sp-odata-types';
//Action Creators to create and return Actions
export const updateTitle = (title: string): Action => ({
type: ActionTypes.UPDATE_TITLE,
payload: title
});
//Each AJAX request ideally has 3 actions: request, success and error.
//These can be used to modify the ui such as show a loading icon, show updated list, show error message etc.
const addListRequest = (): Action => ({
type: ActionTypes.ADD_LIST_REQUEST
});
const addListSuccess = (list: string): Action => ({
type: ActionTypes.ADD_LIST_SUCCESS,
payload: list
});
const addListError = (error: Error): Action => ({
type: ActionTypes.ADD_LIST_ERROR,
payload: error.message
});
//Actions for getLists
const getListsRequest = (): Action => ({
type: ActionTypes.GET_LISTS_REQUEST
});
const getListsSuccess = (lists: string[]): Action => ({
type: ActionTypes.GET_LISTS_SUCCESS,
payload: lists
});
const getListsError = (error: Error): Action => ({
type: ActionTypes.GET_LISTS_ERROR,
payload: error.message
});
export function addList(spHttpClient: SPHttpClient, currentWebUrl: string, listTitle: string) {
return async (dispatch: any) => {
//Fire the 'request' action if you want to update the state to specify that an ajax request is being made.
//This can be used to show a loading screen or a spinner.
dispatch(addListRequest());
const spOpts: ISPHttpClientOptions = {
body: `{ Title: '${listTitle}', BaseTemplate: 100 }`
};
try {
const response: SPHttpClientResponse = await spHttpClient.post(`${currentWebUrl}/_api/web/lists`, SPHttpClient.configurations.v1, spOpts);
const list: IODataList = await response.json();
//Fire the 'success' action when you want to update the state based on a successfull request.
dispatch(addListSuccess(list.Title));
} catch (error) {
//Fire the 'error' action when you want to update the state based on an error request.
dispatch(addListError(error));
}
};
}
export function getLists(spHttpClient: SPHttpClient, currentWebUrl: string) {
return async (dispatch: any) => {
dispatch(getListsRequest());
try {
const response: SPHttpClientResponse = await spHttpClient.get(`${currentWebUrl}/_api/web/lists?$filter=Hidden eq false&$select=Title`, SPHttpClient.configurations.v1);
const responseJSON = await response.json();
const lists: IODataList[] = responseJSON.value;
const listTitles: string[] = lists.map(list => list.Title);
dispatch(getListsSuccess(listTitles));
} catch (error) {
dispatch(getListsError(error));
}
};
}

View File

@ -0,0 +1,52 @@
.demo {
.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 {
padding: 20px;
}
.listItem {
max-width: 715px;
margin: 5px auto 5px auto;
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.button {
// Our button
text-decoration: none;
height: 32px;
// Primary Button
min-width: 80px;
background-color: #0078d7;
border-color: #0078d7;
color: #ffffff;
// Basic Button
outline: transparent;
position: relative;
font-family: "Segoe UI WestEuropean","Segoe UI",-apple-system,BlinkMacSystemFont,Roboto,"Helvetica Neue",sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
font-weight: 400;
border-width: 0;
text-align: center;
cursor: pointer;
display: inline-block;
padding: 0 16px;
.label {
font-weight: 600;
font-size: 14px;
height: 32px;
line-height: 32px;
margin: 0 4px;
vertical-align: top;
display: inline-block;
}
}
}

View File

@ -0,0 +1,85 @@
import * as React from 'react';
import styles from './Demo.module.scss';
import { IDemoProps } from './IDemoProps';
import { escape } from '@microsoft/sp-lodash-subset';
import { Dispatch } from 'redux';
import { updateTitle, addList, getLists } from '../actions/listActions';
import { connect } from 'react-redux';
import { ListState } from '../state/ListState';
import { SPHttpClient } from '@microsoft/sp-http';
import * as ReactDOM from 'react-dom';
interface IConnectedDispatch {
updateTitle: (title: string) => void;
getLists: (spHttpClient: SPHttpClient, currentWebUrl: string) => void;
addList: (spHttpClient: SPHttpClient, currentWebUrl: string, listtitle: string) => void;
}
interface IConnectedState {
title: string;
lists: string[];
}
//Map the application state to the properties of the Components. Making them available in this.props inside the component.
function mapStateToProps(state: ListState, ownProps: IDemoProps): IConnectedState {
return {
title: state.title,
lists: state.lists
};
}
//Map the actions to the properties of the Component. Making them available in this.props inside the component.
const mapDispatchToProps = (dispatch: Dispatch<ListState>): IConnectedDispatch => ({
updateTitle: (title: string) => {
dispatch(updateTitle(title));
},
getLists: (spHttpClient: SPHttpClient, currentWebUrl: string) => {
dispatch(getLists(spHttpClient, currentWebUrl));
},
addList: (spHttpClient: SPHttpClient, currentWebUrl: string, listtitle: string) => {
dispatch(addList(spHttpClient, currentWebUrl, listtitle));
}
});
//Components does not have a state of it's own. The state is is passed to the component as props from mapStateToProps
class Demo extends React.Component<IDemoProps & IConnectedState & IConnectedDispatch, {}> {
public render() {
return (
<div className={styles.demo}>
<div className={styles.container}>
<div className={`ms-Grid-row ms-bgColor-themeDark ms-fontColor-white ${styles.row}`}>
<div className="ms-Grid-col ms-u-lg10 ms-u-xl8 ms-u-xlPush2 ms-u-lgPush1">
<span className="ms-font-xl ms-fontColor-white">Welcome to SharePoint!</span>
<p className="ms-font-l ms-fontColor-white">{escape(this.props.description)}</p>
<span>Add New List:</span>
<div>
<input type="text" value={this.props.title} onChange={this.handleChange.bind(this)} />
<input type="submit" value="Add" onClick={this.handleSubmit.bind(this)} />
</div>
<ul>
{this.props.lists.map(list => {
return <li>{list}</li>;
})}
</ul>
</div>
</div>
</div>
</div>
);
}
private handleChange(event: React.FormEvent<HTMLInputElement>){
this.props.updateTitle(event.currentTarget.value);
}
private handleSubmit(event: React.MouseEvent<HTMLInputElement>){
this.props.addList(this.props.spHttpClient, this.props.currentWebUrl, this.props.title);
}
private componentDidMount() {
this.props.getLists(this.props.spHttpClient, this.props.currentWebUrl);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(Demo);

View File

@ -0,0 +1,10 @@
import { ListState } from '../state/ListState';
import { Store } from 'redux';
import { SPHttpClient } from '@microsoft/sp-http';
export interface IDemoProps {
store: Store<ListState>;
description: string;
spHttpClient: SPHttpClient;
currentWebUrl: string;
}

View File

@ -0,0 +1,7 @@
define([], function() {
return {
"PropertyPaneDescription": "Description",
"BasicGroupName": "Group Name",
"DescriptionFieldLabel": "Description Field"
}
});

View File

@ -0,0 +1,10 @@
declare interface IDemoStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
}
declare module 'demoStrings' {
const strings: IDemoStrings;
export = strings;
}

View File

@ -0,0 +1,28 @@
import { Action, ActionTypes } from '../actions/actionTypes';
import { ListState } from '../state/ListState';
import { Reducer } from 'redux';
const initState = new ListState();
//Reducer determines how the state should change after every action.
const listsReducer: Reducer<ListState> = (state: ListState = initState, action: Action): ListState => {
switch (action.type) {
case ActionTypes.ADD_LIST_REQUEST:
return state; //You can show a loading message here.
case ActionTypes.ADD_LIST_SUCCESS:
return state.addList(action.payload);
case ActionTypes.ADD_LIST_ERROR:
return state;
case ActionTypes.GET_LISTS_REQUEST:
return state;
case ActionTypes.GET_LISTS_SUCCESS:
return state.setLists(action.payload);
case ActionTypes.GET_LISTS_ERROR:
return state; //.setMessage(action.payload); //You can show an error message here
case ActionTypes.UPDATE_TITLE:
return state.setTitle(action.payload);
default: return state;
}
};
export default listsReducer;

View File

@ -0,0 +1,34 @@
import * as Immutable from 'immutable';
export interface IListState {
title: string;
lists: string[];
}
export const initialState: IListState = {
title: "",
lists: []
};
//Immutable State.
export class ListState extends Immutable.Record(initialState) implements IListState {
//Getters
public readonly title: string;
public readonly lists: string[];
//Setters
public setTitle(newTitle: string): ListState {
return this.set("title", newTitle) as ListState;
}
public addList(item: string): ListState {
return this.update("lists", (lists: string[]) => {
return lists.concat(item);
}) as ListState;
}
public setLists(items: string[]): ListState {
return this.set("lists", items) as ListState;
}
}

View File

@ -0,0 +1,15 @@
import { createStore, applyMiddleware, Store } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { createLogger } from 'redux-logger';
import listsReducer from '../reducers/listsReducer';
import { ListState } from '../state/ListState';
const loggerMiddleware = createLogger();
export default function configureStore() {
//do not use loggerMiddleware in production
const listSateStore: Store<ListState> = createStore<ListState>(listsReducer, applyMiddleware(thunkMiddleware, loggerMiddleware));
return listSateStore;
}

View File

@ -0,0 +1,9 @@
/// <reference types="mocha" />
import { assert } from 'chai';
describe('DemoWebPart', () => {
it('should do something', () => {
assert.ok(true);
});
});

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "commonjs",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"types": [
"es6-promise",
"es6-collections",
"webpack-env"
]
}
}

View File

@ -0,0 +1,11 @@
// Type definitions for Microsoft ODSP projects
// Project: ODSP
/* Global definition for UNIT_TEST builds
Code that is wrapped inside an if(UNIT_TEST) {...}
block will not be included in the final bundle when the
--ship flag is specified */
declare const UNIT_TEST: boolean;
/* Global defintion for SPO builds */
declare const DATACENTER: boolean;

View File

@ -0,0 +1 @@
/// <reference path="@ms/odsp.d.ts" />