Merge remote-tracking branch 'upstream/master'

This commit is contained in:
AJIXuMuK 2020-04-24 20:36:52 -07:00
commit 6647cd4d00
105 changed files with 59818 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

32
samples/react-add-js-css-ref/.gitignore vendored Normal file
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": {
"isCreatingSolution": false,
"environment": "spo",
"version": "1.9.1",
"libraryName": "react-add-js-css-ref",
"libraryId": "d9c30e1a-bf06-46fa-807d-ce5182d9c91c",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,111 @@
---
page_type: sample
products:
- office-sp
languages:
- javascript
- typescript
extensions:
contentType: samples
technologies:
- SharePoint Framework
platforms:
- react
createdDate: 04/24/2020 12:00:00 AM
---
## SPFx webpart to add JS and CSS reference on Modern Pages via SPFx application customizer extension
This repo is a react based SPFx web part and extension that allows users to add/modify/delete custom js and css file references using SPFx application customizer extension all modern pages within SP online site. This web part provides an interface to JS and CSS file references so that we don't have to modify code when we need to change references or add new references in the future. As part of security measures, this actions on web part can be only accessed by users who have Manage web permission on site.
WebPart in Action
![Webpart in action](assets/webpartinaction.gif?raw=true "Webpart in action")
Challanges/Drawback with ONLY using SPFx extension for adding js and css file references.
* JS and CSS file references links needs to be hardcoded in solution
* Changes to code required if we need to change add new reference or remove existing reference.
* Redeployment of package and installation
* Different solution would be required for different site collections as we would definitely need different header js and css file references for each site collection(most of cases)
* High maintenance and time consuming for simple task.
To overcome this drawbacks, this solution comes handy. This is reusable component which can be used by developers to eliminate creating Extension on thier own. Feel free to connect on twitter:@siddh_me for any details.
### Features of solution
* WebPart to configure JS and CSS file reference.
* Edit functionality if at least one JS or CSS reference is already added via this solution
* Completely remove all the references added via this solution
* Support for relative url also, if your js and css file is referred from some document library in same site collection.
Path can be `/sites/mysc/style library/js/custom.js` or `/sites/mysc/style library/css/custom.css`
## Used SharePoint Framework Version
![1.9.1](https://img.shields.io/badge/version-1.9.1-green.svg)
## Applies to
* [SharePoint Framework](http://dev.office.com/sharepoint/docs/spfx/sharepoint-framework-overview)
* [Office 365 tenant](http://dev.office.com/sharepoint/docs/spfx/set-up-your-developer-tenant)
### Package and Deploy
Note - If you don't want to build and package on your own, you can directly download package at this [location](./sharepoint/solutions/react-add-js-css-ref.sppkg) and upload to app catalog and install app on required site collection. Skip below steps and directly go to How to use section.
Clone the solution and make sure there is no error before packaging. Try first on local work bench.
Change the `pageURL` property in `/config/serve.json` - This should be a valid modern page on your site collection.
```bash
git clone the repo
npm i
gulp serve
```
- Execute the following gulp task to bundle your solution. This executes a release build of your project by using a dynamic label as the host URL for your assets. This URL is automatically updated based on your tenant CDN settings:
```bash
gulp bundle --ship
```
- Execute the following task to package your solution. This creates an updated `webpart.sppkg` package on the `sharepoint/solution` folder.
```bash
gulp package-solution --ship
```
- Upload or drag and drop the newly created client-side solution package to the app catalog in your tenant.
- Based on your tenant settings, if you would not have CDN enabled in your tenant, and the `includeClientSideAssets` setting would be true in the `package-solution.json`, the loading URL for the assets would be dynamically updated and pointing directly to the `ClientSideAssets` folder located in the app catalog site collection.
### How to Use Solution
* Once app is deployed to app catalog successfully.
* Install app to required site collection
* Create new modern page. Add **addJsCssReference** webpart to page. Publish the page.
* Use grid to add js and css file references, both are separate sections.
* On Success message - Refresh the page and you would see your js and css files will be loaded.
* To Edit/Remove, go to same page again and Use **Activate** or **Deactivate**.
* Only Users with Manage Web permission will be able to access webpart and add/modify references.
### High level design of Solution
* SPFx solution with 2 components 1. SPFx Webpart 2. SPFx Extension Application Customizer
* Disables Automatic activation of SPFx extension when app is installed.
* React based solution
* Register Custom action with ClientSideComponentId of Extension component
* Passes parameters to Extension with ClientSideComponentProperties
## Solution
Solution|Author(s)
--------|---------
react-add-js-css-ref | [Siddharth Vaghasia](https://www.linkedin.com/in/siddharthvaghasia/)
## Version history
Version|Date|Comments
-------|----|--------
1.0.0|Apr 24, 2020|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
For any issue or help, Buzz me on twitter:([siddh_me](https://twitter.com/siddh_me/))
> Sharing is caring!
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-add-js-css-ref" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

View File

@ -0,0 +1,28 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"reference-injector-application-customizer": {
"components": [
{
"entrypoint": "./lib/extensions/referenceInjector/ReferenceInjectorApplicationCustomizer.js",
"manifest": "./src/extensions/referenceInjector/ReferenceInjectorApplicationCustomizer.manifest.json"
}
]
},
"add-js-css-reference-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/addJsCssReference/AddJsCssReferenceWebPart.js",
"manifest": "./src/webparts/addJsCssReference/AddJsCssReferenceWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"ReferenceInjectorApplicationCustomizerStrings": "lib/extensions/referenceInjector/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js",
"AddJsCssReferenceWebPartStrings": "lib/webparts/addJsCssReference/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": "react-add-js-css-ref",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,27 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "react-add-js-css-ref-client-side-solution",
"id": "d9c30e1a-bf06-46fa-807d-ce5182d9c91c",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false,
"features": [
{
"title": "Application Extension - Deployment of custom action.",
"description": "Deploys a custom action with ClientSideComponentId association",
"id": "201c7969-a60d-492a-83d2-db3088515c51",
"version": "1.0.0.0",
"assets": {
"elementManifests": [
"elements.xml",
"clientsideinstance.xml"
]
}
}
]
},
"paths": {
"zippedPackage": "solution/react-add-js-css-ref.sppkg"
}
}

View File

@ -0,0 +1,34 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"serveConfigurations": {
"default": {
"pageUrl": "https://contoso.sharepoint.com/sites/mysc/_layouts/15/viewlsts.aspx",
"customActions": {
"38afa8d7-b498-4529-9f99-6279392f9309": {
"location": "ClientSideExtension.ApplicationCustomizer",
"properties": {
"testMessage": "Test message"
}
}
}
},
"referenceInjector": {
"pageUrl": "https://contoso.sharepoint.com/sites/mysc/_layouts/15/viewlsts.aspx",
"customActions": {
"38afa8d7-b498-4529-9f99-6279392f9309": {
"location": "ClientSideExtension.ApplicationCustomizer",
"properties": {
"testMessage": "Test message"
}
}
}
}
},
"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,8 @@
'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.addSuppression(`Warning - [sass] The local CSS class 'ms-DetailsList' is not camelCase and will not be type-safe.`);
build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
{
"name": "react-add-js-css-ref",
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/decorators": "1.9.1",
"@microsoft/sp-application-base": "1.9.1",
"@microsoft/sp-core-library": "1.9.1",
"@microsoft/sp-dialog": "1.9.1",
"@microsoft/sp-lodash-subset": "1.9.1",
"@microsoft/sp-office-ui-fabric-core": "1.9.1",
"@microsoft/sp-webpart-base": "1.9.1",
"@pnp/sp": "^2.0.3",
"@pnp/spfx-controls-react": "1.16.0",
"@types/es6-promise": "0.0.33",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1",
"office-ui-fabric-react": "6.189.2",
"react": "16.8.5",
"react-dom": "16.8.5"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.9.1",
"@microsoft/sp-tslint-rules": "1.9.1",
"@microsoft/sp-module-interfaces": "1.9.1",
"@microsoft/sp-webpart-workbench": "1.9.1",
"@microsoft/rush-stack-compiler-2.9": "0.7.16",
"gulp": "~3.9.1",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2"
},
"resolutions": {
"@types/react": "16.8.8"
}
}

View File

@ -0,0 +1,17 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-extension-manifest.schema.json",
"id": "38afa8d7-b498-4529-9f99-6279392f9309",
"alias": "ReferenceInjectorApplicationCustomizer",
"componentType": "Extension",
"extensionType": "ApplicationCustomizer",
// 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
}

View File

@ -0,0 +1,56 @@
import { override } from '@microsoft/decorators';
import { Log } from '@microsoft/sp-core-library';
import {
BaseApplicationCustomizer
} from '@microsoft/sp-application-base';
import { Dialog } from '@microsoft/sp-dialog';
import * as strings from 'ReferenceInjectorApplicationCustomizerStrings';
const LOG_SOURCE: string = 'ReferenceInjectorApplicationCustomizer';
/**
* If your command set uses the ClientSideComponentProperties JSON input,
* it will be deserialized into the BaseExtension.properties object.
* You can define an interface to describe it.
*/
export interface IReferenceInjectorApplicationCustomizerProperties {
// This is an example; replace with your own property
jsfiles:any[];
cssfiles:any[];
}
/** A Custom Action which can be run during execution of a Client Side Application */
export default class ReferenceInjectorApplicationCustomizer
extends BaseApplicationCustomizer<IReferenceInjectorApplicationCustomizerProperties> {
@override
public onInit(): Promise<void> {
Log.info(LOG_SOURCE, `Initialized ${strings.Title}`);
if(this.properties.jsfiles)
{
this.properties.jsfiles.forEach(element => {
let JsTag: HTMLScriptElement = document.createElement("script");
JsTag.src = element.FilePath;
JsTag.type = "text/javascript";
document.body.appendChild(JsTag);
});
}
if(this.properties.cssfiles){
this.properties.cssfiles.forEach(element => {
let cssLink: HTMLLinkElement = document.createElement("link");
cssLink.href = element.FilePath;
cssLink.type = "text/css";
cssLink.rel = "stylesheet";
document.body.appendChild(cssLink);
});
}
return Promise.resolve();
}
}

View File

@ -0,0 +1,5 @@
define([], function() {
return {
"Title": "ReferenceInjectorApplicationCustomizer"
}
});

View File

@ -0,0 +1,8 @@
declare interface IReferenceInjectorApplicationCustomizerStrings {
Title: string;
}
declare module 'ReferenceInjectorApplicationCustomizerStrings' {
const strings: IReferenceInjectorApplicationCustomizerStrings;
export = strings;
}

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,27 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "9fa13c84-2736-4413-be49-e163b796b143",
"alias": "AddJsCssReferenceWebPart",
"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": "AddJsCssReference" },
"description": { "default": "AddJsCssReference description" },
"officeFabricIconFontName": "Page",
"properties": {
"description": "AddJsCssReference"
}
}]
}

View File

@ -0,0 +1,61 @@
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 'AddJsCssReferenceWebPartStrings';
import AddJsCssReference from './components/AddJsCssReference';
import { IAddJsCssReferenceProps } from './components/IAddJsCssReferenceProps';
export interface IAddJsCssReferenceWebPartProps {
description: string;
}
export default class AddJsCssReferenceWebPart extends BaseClientSideWebPart<IAddJsCssReferenceWebPartProps> {
public render(): void {
const element: React.ReactElement<IAddJsCssReferenceProps > = React.createElement(
AddJsCssReference,
{
description: this.properties.description,
context:this.context
}
);
ReactDom.render(element, 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: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneTextField('description', {
label: strings.DescriptionFieldLabel
})
]
}
]
}
]
};
}
}

View File

@ -0,0 +1,91 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.addJsCssReference {
.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);
padding: 30px;
}
.row {
@include ms-Grid-row;
@include ms-fontColor-black;
// background-color: $ms-color-themeDark;
// padding: 20px;
}
.column {
// @include ms-Grid-col;
@include ms-lg12;
// @include ms-xl8;
// @include ms-xlPush2;
// @include ms-lgPush1;
}
.title {
@include ms-font-xl;
@include ms-fontColor-black;
font-weight: 500;
}
.subTitle {
@include ms-font-l;
@include ms-fontColor-black;
}
.description {
@include ms-font-l;
@include ms-fontColor-black;
}
.button {
// Our button
text-decoration: none;
height: 32px;
// Primary Button
min-width: 80px;
background-color: $ms-color-themePrimary;
border-color: $ms-color-themePrimary;
color: $ms-color-white;
// 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: $ms-font-size-m;
font-weight: $ms-font-weight-regular;
border-width: 0;
text-align: center;
cursor: pointer;
display: inline-block;
padding: 0 16px;
.label {
font-weight: $ms-font-weight-semibold;
font-size: $ms-font-size-m;
height: 32px;
line-height: 32px;
margin: 0 4px;
vertical-align: top;
display: inline-block;
}
}
.customicons{
font-size: 14px;
color:$ms-color-themePrimary;
}
.filepath{
font-size: 14px;
}
.ms-DetailsList{
font-size: 14px;
}
}

View File

@ -0,0 +1,491 @@
import * as React from 'react';
import styles from './AddJsCssReference.module.scss';
import { IAddJsCssReferenceProps } from './IAddJsCssReferenceProps';
import { escape } from '@microsoft/sp-lodash-subset';
import { TextField, MaskedTextField } from 'office-ui-fabric-react/lib/TextField';
import { ListView, IViewField, SelectionMode} from "@pnp/spfx-controls-react/lib/ListView";
import {MessageBarType,Link,Separator, CommandBarButton,IStackStyles,Text,MessageBar,PrimaryButton,DefaultButton,Dialog,DialogFooter,DialogType,Stack, IStackTokens, updateA } from 'office-ui-fabric-react';
import { sp} from "@pnp/sp";
import "@pnp/sp/webs";
import "@pnp/sp/user-custom-actions";
import "@pnp/sp/presets/all";
import {TypedHash} from "@pnp/common";
import { IUserCustomActionAddResult,IUserCustomActionUpdateResult,IUserCustomAction } from '@pnp/sp/user-custom-actions';
import { createTheme, ITheme } from 'office-ui-fabric-react/lib/Styling';
import { mergeStyleSets } from 'office-ui-fabric-react/lib/Styling';
import { PermissionKind } from '@pnp/sp/presets/all';
const stackTokens: IStackTokens = { childrenGap: 40 };
const CustomActionTitle = 'JSCssAppCustomizer';
const ApplicationCustomizerComponentID = '38afa8d7-b498-4529-9f99-6279392f9309';
const description = 'This user action is of type application customizer to custom js and css file references via SFPx extension';
const theme: ITheme = createTheme({
fonts: {
medium: {
// fontFamily: 'Monaco, Menlo, Consolas',
fontSize: '18px'
}
}
});
const stackStyles: Partial<IStackStyles> = { root: { height: 30 } };
export interface IAddJsCssReferenceState {
disableRegisterButton: boolean;
disableRemoveButton: boolean;
jsfiles:any[];
cssfiles:any[];
currentjsRef:string;
currentcssRef:string;
hideJSDailog:boolean;
hideCSSDailog:boolean;
currentCustomAction:any;
isEdit:boolean;
editIndex:number;
showMesssage:boolean;
successmessage:string;
userHasPermission:boolean;
}
export default class AddJsCssReference extends React.Component<IAddJsCssReferenceProps, IAddJsCssReferenceState> {
private viewFields: any[] = [
{
name: "Type",
displayName: "Action",
minWidth: 60,
maxWidth:60,
render: (item,index) =>{
console.log(item);
return (
<React.Fragment>
<Stack horizontal tokens={{childrenGap:20}}>
<i className={"ms-Icon ms-Icon--Edit " + styles.customicons} onClick={()=> this.editClicked(item,index)} aria-hidden="true"></i>
<i className={"ms-Icon ms-Icon--Delete " + styles.customicons} onClick={()=> this.deleteClicked(item,index)} aria-hidden="true"></i>
</Stack>
</React.Fragment>
);
},
className:"test"
},
{
name: "FilePath",
displayName: "FilePath",
minWidth:600,
render: (item,index) =>{
console.log(item);
return (
<React.Fragment>
<span className={styles.filepath}>
{item.FilePath}
</span>
</React.Fragment>
);
}
// maxWidth:800
}
];
constructor(props: IAddJsCssReferenceProps,state:IAddJsCssReferenceProps) {
super(props);
this.state = {
disableRegisterButton:false,
disableRemoveButton:false,
jsfiles:[],
cssfiles:[],
currentjsRef:"",
currentcssRef:"",
hideJSDailog:true,
hideCSSDailog:true,
currentCustomAction:null,
isEdit:false,
editIndex:-1,
showMesssage:false,
successmessage:"",
userHasPermission:false
};
sp.setup(this.props.context);
}
public render(): React.ReactElement<IAddJsCssReferenceProps> {
return (
<React.Fragment>
{this.state.userHasPermission &&
<div className={styles.addJsCssReference}>
<div className={ styles.container }>
<div className={ styles.row }>
<div className={ styles.column }>
<span className={ styles.title }>SPFx JS CSS References WebPart</span>
<p className={ styles.subTitle }>This webpart can be used to add reference to custom js files and css files via SPFx extension application customizer.</p>
</div>
<div className={ styles.row }>
<div className={ styles.column }>
{this.state.showMesssage &&
<MessageBar dismissButtonAriaLabel="Close" onDismiss={()=>{ this.setState({showMesssage:false});}} messageBarType={MessageBarType.success}>
{this.state.successmessage}
</MessageBar>
}
{this.state.currentCustomAction && this.state.showMesssage != true &&
<MessageBar >
We found you already have some custom js and css files references added via this customizer. Feel free to Edit or Remove references.
</MessageBar>
}
<div id="jsfiles">
<Separator></Separator>
<Stack horizontal styles={stackStyles} tokens={stackTokens}>
<Text theme={theme}>Javascript Files</Text>
<CommandBarButton iconProps={{iconName: 'Add'}} text="Add JS Link" onClick={()=>this.openAddJSDailog()} />
</Stack>
<Separator></Separator>
{/* <PrimaryButton text="Add New Item" } /> */}
{this.state.jsfiles.length === 0 &&
<React.Fragment>
<MessageBar>
No References Found.
<Link href="#" onClick={()=>this.openAddJSDailog()}>
Click here
</Link>
<Text> to add new.</Text>
</MessageBar>
<br/>
</React.Fragment>
}
{this.state.jsfiles.length >0 &&
<ListView
items={this.state.jsfiles}
viewFields={this.viewFields}
/>
}
<Dialog
minWidth={600}
maxWidth={900}
hidden={this.state.hideJSDailog}
onDismiss={this._closeJSDialog}
dialogContentProps={{
type: DialogType.normal,
title: 'Add JS Reference',
// subText: 'Enter a valid JS file link.'
}}
modalProps={{
isBlocking: false,
// styles: { main: { maxWidth: 450 } }
}}
>
<TextField required onChange={evt => this.updateJSValue(evt)} value={this.state.currentjsRef} label="URL" resizable={false} />
<DialogFooter>
<PrimaryButton onClick={()=>this._addJsReference()} text="Add" />
<DefaultButton onClick={this._closeJSDialog} text="Cancel" />
</DialogFooter>
</Dialog>
</div>
<div id="cssfiles">
<br/>
<Stack horizontal styles={stackStyles} tokens={stackTokens}>
<Text theme={theme}>CSS Files</Text>
<CommandBarButton iconProps={{iconName: 'Add'}} text="Add CSS Link" onClick={()=>this.openAddCSSDailog()} />
</Stack>
{/* <PrimaryButton text="Add New Item" onClick={()=>this.openAddCSSDailog()} /> */}
<Separator></Separator>
{this.state.cssfiles.length === 0 &&
<React.Fragment>
<MessageBar>
No References Found.
<Link href="#" onClick={()=>this.openAddCSSDailog()}>
Click here
</Link>
<Text> to add new.</Text>
</MessageBar>
<br/>
</React.Fragment>
}
{this.state.cssfiles.length > 0 &&
<ListView
items={this.state.cssfiles}
viewFields={this.viewFields}
// iconFieldName="ServerRelativeUrl"
// selectionMode={SelectionMode.multiple}
// selection={this._getSelection}
/>
}
<Dialog
minWidth={600}
maxWidth={900}
hidden={this.state.hideCSSDailog}
onDismiss={this._closeCSSDialog}
dialogContentProps={{
type: DialogType.normal,
title: 'Add CSS Reference',
// subText: 'Enter a valid CSS file link.'
}}
modalProps={{
isBlocking: false,
// styles: { main: { minWidth: 500 !important ,width:500} }
}}
>
<TextField required onChange={evt => this.updateCSSValue(evt)} value={this.state.currentcssRef} label="URL" />
<DialogFooter>
<PrimaryButton onClick={()=>this._addCSSReference()} text="Add" />
<DefaultButton onClick={this._closeCSSDialog} text="Cancel" />
</DialogFooter>
</Dialog>
</div>
<br/>
<Stack horizontal tokens={stackTokens}>
<PrimaryButton text="Activate" onClick={()=>this._registerClicked()} disabled={(this.state.jsfiles.length>0 || this.state.cssfiles.length>0 )?false:true} />
<DefaultButton text="Deactivate" onClick={()=> this._removeClicked()} disabled={this.state.currentCustomAction==null?true:false} />
</Stack>
</div>
</div>
</div>
</div>
<div>
</div>
</div>
}
{
<MessageBar messageBarType={MessageBarType.severeWarning}>
Access denied, you do not have permission to access this section. Please connect with your site admins.
</MessageBar>
}
</React.Fragment>
);
}
public componentDidMount() {
this.checkPermisson();
}
private async checkPermisson(){
const perms2 = await sp.web.currentUserHasPermissions(PermissionKind.ManageWeb);
console.log(perms2);
var temp = false;
if(temp){
this.getCustomAction();
}
}
private _registerClicked(): void {
this.setCustomAction();
}
private _removeClicked(): void {
const uca = sp.web.userCustomActions.getById(this.state.currentCustomAction.Id);
const response = uca.delete();
console.log("removed custom action");
console.log(response);
this.setState({currentCustomAction:null,jsfiles:[],cssfiles:[],
showMesssage:true,successmessage:"Application Customizer deactivated sucessfully."});
}
private updateJSValue(evt) {
this.setState({
currentjsRef: evt.target.value
});
}
private updateCSSValue(evt) {
this.setState({
currentcssRef: evt.target.value
});
}
private editClicked (item,index){
if(item.Type == "js") {
this.setState({hideJSDailog:false,
currentjsRef:item.FilePath,
isEdit:true,
editIndex:index
});
}
if(item.Type == "css") {
this.setState({hideCSSDailog:false,
currentcssRef:item.FilePath,
isEdit:true,
editIndex:index
});
}
}
private deleteClicked (item,index){
if(item.Type == "css") {
let currentitems = this.state.cssfiles.map((x) => x);
currentitems.splice(index,1);
this.setState({cssfiles:currentitems});
}
else if(item.Type == "js"){
let currentitems = this.state.jsfiles.map((x) => x);
currentitems.splice(index,1);
this.setState({jsfiles:currentitems});
}
}
private openAddJSDailog (){
this.setState({hideJSDailog:false});
}
private openAddCSSDailog (){
this.setState({hideCSSDailog:false});
}
private _addJsReference (){
if(!this.state.isEdit){
var item = {
FilePath:this.state.currentjsRef,
Type: "js"
};
let currentitems = this.state.jsfiles.map((x) => x);
currentitems.push(item);
currentitems[this.state.jsfiles.length] = item;
this.setState({jsfiles:currentitems,
hideJSDailog:true,currentjsRef:""});
}
else{
item = {
FilePath:this.state.currentjsRef,
Type: "js"
};
let currentitems = this.state.jsfiles.map((x) => x);
currentitems[this.state.editIndex] = item;
this.setState({jsfiles:currentitems,
hideJSDailog:true,currentjsRef:"",
isEdit:false,editIndex:-1});
}
}
private _addCSSReference (){
if(!this.state.isEdit){
console.log("add item to grid");
var item = {
FilePath:this.state.currentcssRef,
Type:"css"
};
let currentitems = this.state.cssfiles.map((x) => x);
currentitems.push(item);
currentitems[this.state.cssfiles.length] = item;
this.setState({cssfiles:currentitems,
hideCSSDailog:true,
currentcssRef:""});
}
else{
console.log("add item to grid");
item = {
FilePath:this.state.currentcssRef,
Type:"css"
};
let currentitems = this.state.cssfiles.map((x) => x);
currentitems[this.state.editIndex] = item;
this.setState({cssfiles:currentitems,
hideCSSDailog:true,
currentcssRef:"",
editIndex:-1,isEdit:false});
}
}
private _closeJSDialog = (): void => {
this.setState({ hideJSDailog: true });
}
private _closeCSSDialog = (): void => {
this.setState({ hideCSSDailog: true });
}
private async getCustomAction(){
var web = await sp.web.get();
console.log(web);
var customactions:any = await sp.web.userCustomActions.get();
console.log(customactions);
var found = customactions.filter(item => item.Title == CustomActionTitle);
if (found.length > 0) {
this.setState({currentCustomAction:found[0]});
var jsonproperties = found[0].ClientSideComponentProperties;
var jsfileArray = JSON.parse(jsonproperties).jsfiles;
var cssfileArray = JSON.parse(jsonproperties).cssfiles;
console.log(jsfileArray);
console.log(cssfileArray);
this.setState({jsfiles:jsfileArray,cssfiles:cssfileArray});
}
}
protected async setCustomAction() {
try {
const payload: TypedHash<string> = {
"Title": CustomActionTitle,
"Description": description,
"Location": 'ClientSideExtension.ApplicationCustomizer',
ClientSideComponentId:ApplicationCustomizerComponentID,
ClientSideComponentProperties: JSON.stringify({jsfiles:this.state.jsfiles,cssfiles:this.state.cssfiles }),
};
if(this.state.currentCustomAction == null) {
const response : IUserCustomActionAddResult = await sp.web.userCustomActions.add(payload);
console.log(response);
const uca = await sp.web.userCustomActions.getById(response.data.Id);
this.setState({currentCustomAction: uca,showMesssage:true,successmessage:"Application customizer activated sucessfully."});
}
else{
const uca = sp.web.userCustomActions.getById(this.state.currentCustomAction.Id);
const response: IUserCustomActionUpdateResult =await uca.update(payload);
const ucaupdated = await sp.web.userCustomActions.getById(response.data.Id);
this.setState({currentCustomAction: ucaupdated,showMesssage:true,successmessage:"Application customizer updated sucessfully."});
}
} catch (error) {
console.error(error);
}
}
}

View File

@ -0,0 +1,6 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
export interface IAddJsCssReferenceProps {
description: string;
context:WebPartContext;
}

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 IAddJsCssReferenceWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
}
declare module 'AddJsCssReferenceWebPartStrings' {
const strings: IAddJsCssReferenceWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,38 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-2.9/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
}
}

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": {
"isCreatingSolution": false,
"environment": "spo",
"version": "1.10.0",
"libraryName": "spfx-css-in-js",
"libraryId": "d07a3ba7-a2e7-41df-b2e6-acaf2ed00a33",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,57 @@
# "CSS in JS" with SharePoint Framework and TypeStyle
## Summary
The web part demonstrates the usage of "CSS in JS" pattern with SharePoint Framework. "CSS in JS" is implemented using [TypeStyle](https://typestyle.github.io/) library. Read more in [the blog post here](https://spblog.net/post/2020/04/22/styling-sharepoint-framework-components-with-css-in-js-approach).
![picture of the web part in action](assets/dynamic-styles.gif)
## Used SharePoint Framework Version
![1.10.0](https://img.shields.io/badge/version-1.10.0-green.svg)
## Applies to
* [SharePoint Framework](https:/dev.office.com/sharepoint)
* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
## Solution
Solution|Author(s)
--------|---------
react-css-in-js-typestyle|[@Sergei Sergeev](https://twitter.com/sergeev_srg) - [Mastaq](https://mastaq.com/)
## Version history
Version|Date|Comments
-------|----|--------
1.0|April 24, 2020|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
* Clone this repository
* in the command line run:
* `npm install`
* `gulp build`
* `gulp bundle --ship`
* `gulp package-solution --ship`
* add the webpart to your tenant app store
* add the app to a SharePoint site and then add the webpart to the page
## Features
This Web Part illustrates the following concepts on top of the SharePoint Framework:
* "CSS in JS" pattern adopted to SharePoint Framework
* Theme support using SharePoint Framework's `ThemeProvider`
* Dynamically reacting to theme changes without affecting performance
* React hooks
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-css-in-js-typestyle" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,18 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"dynamic-type-style-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/dynamicTypeStyle/DynamicTypeStyleWebPart.js",
"manifest": "./src/webparts/dynamicTypeStyle/DynamicTypeStyleWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"DynamicTypeStyleWebPartStrings": "lib/webparts/dynamicTypeStyle/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": "spfx-css-in-js",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,13 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "spfx-css-in-js-client-side-solution",
"id": "d07a3ba7-a2e7-41df-b2e6-acaf2ed00a33",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false
},
"paths": {
"zippedPackage": "solution/spfx-css-in-js.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,7 @@
'use strict';
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.initialize(require('gulp'));

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
{
"name": "spfx-css-in-js",
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.10.0",
"@microsoft/sp-lodash-subset": "1.10.0",
"@microsoft/sp-office-ui-fabric-core": "1.10.0",
"@microsoft/sp-property-pane": "1.10.0",
"@microsoft/sp-webpart-base": "1.10.0",
"@types/es6-promise": "0.0.33",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1",
"office-ui-fabric-react": "6.189.2",
"react": "16.8.5",
"react-dom": "16.8.5",
"typestyle": "^2.0.4"
},
"resolutions": {
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.10.0",
"@microsoft/sp-tslint-rules": "1.10.0",
"@microsoft/sp-module-interfaces": "1.10.0",
"@microsoft/sp-webpart-workbench": "1.10.0",
"@microsoft/rush-stack-compiler-3.3": "0.3.5",
"gulp": "~3.9.1",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2"
}
}

View File

@ -0,0 +1,6 @@
import { createContext } from 'react';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
const AppContext = createContext<{ theme: IReadonlyTheme }>(undefined);
export default AppContext;

View File

@ -0,0 +1,12 @@
import { IReadonlyTheme } from "@microsoft/sp-component-base";
import { useEffect, useState } from "react";
export const useThemedStyles = <T>(theme: IReadonlyTheme, createStyles: (theme: IReadonlyTheme) => T) => {
const [styles, setStyles] = useState<T>(createStyles.bind(null, theme));
useEffect(() => {
setStyles(createStyles(theme));
}, [theme]);
return styles;
};

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,28 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "0055998e-a48a-4453-bc2e-ecedebed480d",
"alias": "DynamicTypeStyleWebPart",
"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"],
"supportsThemeVariants": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "Other" },
"title": { "default": "DynamicTypeStyle" },
"description": { "default": "DynamicTypeStyle description" },
"officeFabricIconFontName": "Page",
"properties": {
"description": "DynamicTypeStyle"
}
}]
}

View File

@ -0,0 +1,80 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
IPropertyPaneConfiguration,
PropertyPaneTextField
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { ThemeProvider, IReadonlyTheme, ThemeChangedEventArgs } from '@microsoft/sp-component-base';
import * as strings from 'DynamicTypeStyleWebPartStrings';
import { StylesDemo } from './components/StylesDemo';
import AppContext from '../../common/AppContext';
export interface IDynamicTypeStyleWebPartProps {
description: string;
}
export default class DynamicTypeStyleWebPart extends BaseClientSideWebPart<IDynamicTypeStyleWebPartProps> {
private themeProvider: ThemeProvider;
private theme: IReadonlyTheme;
protected onInit(): Promise<void> {
this.themeProvider = this.context.serviceScope.consume(ThemeProvider.serviceKey);
this.theme = this.themeProvider.tryGetTheme();
this.themeProvider.themeChangedEvent.add(this, this.onThemeChanged);
return super.onInit();
}
public render(): void {
const element: React.ReactElement = React.createElement(
AppContext.Provider,
{
value: {
theme: { ...this.theme }
}
},
React.createElement(StylesDemo)
);
ReactDom.render(element, this.domElement);
}
private onThemeChanged(args: ThemeChangedEventArgs) {
this.theme = args.theme;
this.render();
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(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,8 @@
import * as React from 'react';
import { TypeStyleButton } from './TypeStyleButton/TypeStyleButton';
export const StylesDemo: React.FC = () => (
<div>
<div><TypeStyleButton /></div>
</div>
);

View File

@ -0,0 +1,29 @@
import { stylesheet } from "typestyle";
import { IReadonlyTheme } from "@microsoft/sp-component-base";
export const createStyles = (theme: IReadonlyTheme) => {
return stylesheet({
root: {
margin: "10px",
},
myButton: {
backgroundColor: theme.palette.themePrimary,
color: theme.palette.white,
height: "60px",
lineHeight: "60px",
textAlign: "center",
fontSize: "16px",
padding: "0 15px",
cursor: "pointer",
borderWidth: "0 0 6px 0",
fontWeight: "bold",
borderColor: "#4e84c3",
borderStyle: "solid",
$nest: {
"&:hover": {
backgroundColor: "#328bf9",
}
}
}
});
};

View File

@ -0,0 +1,22 @@
import * as React from 'react';
import { createStyles } from "./TypeStyleButton.styles";
import { useThemedStyles } from '../../../../hooks/useThemedStyles';
import AppContext from '../../../../common/AppContext';
export const TypeStyleButton: React.FC = () => {
const { theme } = React.useContext(AppContext);
const styles = useThemedStyles(theme, createStyles);
const [enabled, setEnabled] = React.useState(true);
const toggleEnable = () => {
setEnabled(!enabled);
};
return (
<div className={styles.root}>
<button onClick={toggleEnable} className={styles.myButton}>Hello from a TypeStyle button! The state is {enabled ? "enabled" : "disabled"}</button>
</div>
);
};

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 IDynamicTypeStyleWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
}
declare module 'DynamicTypeStyleWebPartStrings' {
const strings: IDynamicTypeStyleWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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
}
}

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,33 @@
# 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
*.scss.d.ts

View File

@ -0,0 +1,12 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.10.0",
"libraryName": "react-spupsproperty-sync",
"libraryId": "42ba670c-573b-46f9-a529-3990cd2a5bc8",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,100 @@
---
page_type: sample
products:
- office-sp
languages:
- javascript
- typescript
extensions:
contentType: samples
technologies:
- SharePoint Framework
platforms:
- react
createdDate: 04/23/2020 12:00:00 AM
---
# SPUPS Property Sync
## Summary
> This component will help the administrators who are currently maintaining the user profiles in their organization. Since not all the properties from Azure are synced automatically to **SharePoint UPS**, this solution will help administrator to synchronize specific properties (default or custom) from Azure or maintained in a separate system directly to **SharePoint UPS** using property import. Below are the features
* **Property Mapping** will provide a flexible and user-friendly way to pick the properties to be synced.
* **Templates** can be generated based on the '_Property Mapping_'.
* Download the generated templates in both **CSV** & **JSON** format.
* **User selection** to allow you to update only the users whose properties are changed or yet to be updated.
* **User selection** method will allow the admin to update both
* **Manually** entered properties or which are maintained in a separate system
* Properties from **Azure AD**
* **Bulk Sync** will allow the admin to upload the data using the templates generated. They can also use this templates as a base for exporting the data from other system and then feed them here to update the properties.
* **Access control** based on **SharePoint Group**, not all the users can access the applictaion.
* **Anytime access** to the template files generated with different property set and the files uploaded for bulk update.
* Separate section to check the **status** of the property update. **Detailed status** on each property and also display the overall status.
* **Azure Function** to handle the property update. **PnPPowershell** is used in Azure Function.
* The application supports **SPA**.
> **_Note_**: All the supporting lists were created when the web part is loaded for the first time. Whenever the web part is loaded, the supported lists were checked whether it exists or not.
## Properties
1. **Select a library to store the templates**: A document library to store all the templates generated and also the data files uploaded for bulk sync.
2. **Azure Function URL**: Azure function URL to run the property update silently.
3. **Use Certificate for Azure Function authentication**: The video mentioned below to setup Azure Function has different options. This setting will decide whether to use the certificate or stored credentials to communicate with SharePoint.
4. **Date format**: Date format to be used across the entire application. Used _**momentJS**_.
5. **SharePoint Groups**: Only the users from the configured SharePoint Groups and Site Administrator shall be allowed access.
6. **Use page full width**: This is used when the web part is added to a site page where it has to use full width.
> **_Note_**: Only the Site Administrator is allowed to update the application properties.
## Preview
![SPUPS-Property-Sync](./assets/SPUPS-Sync.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)
## Prerequisites
> **@microsoft/generator-sharepoint - 1.10.0**
## Solution
Solution|Author(s)
--------|---------
SPUPS Property Sync | Sudharsan K.([@sudharsank](https://twitter.com/sudharsank), [Know More](http://windowssharepointserver.blogspot.com/))
## Version history
Version|Date|Comments
-------|----|--------
1.0.0.0|Apr 23 2020|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
## Minimal Path to Awesome
- Clone this repository
- in the command line run:
- `npm install`
- `gulp bundle --ship && gulp package-solution --ship`
- Add the .sppkg file to the app catalog and add the **Page Comments** web part to the page.
- **Azure Function** has to be setup for property update. **The actual powershell is uploaded in the assets folder**. Follow the steps explained in the below video by [Paolo Pialorsi](https://www.youtube.com/watch?v=plS_1BsQAto&list=PL-KKED6SsFo8TxDgQmvMO308p51AO1zln&index=2&t=0s).
#### Local Mode
This solution doesn't work on local mode.
#### SharePoint Mode
If you want to try on a real environment, open:
[O365 Workbench](https://your-domain.sharepoint.com/_layouts/15/workbench.aspx)
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-spupsproperty-sync" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

View File

@ -0,0 +1,85 @@
function connectSPOnline ($targeturl, $usecert) {
if($usecert -eq $true) {
# Using cert
$tenant = $env:Tenant
$clientid = $env:ClientID
$thumbprint = $env:Thumbprint
# Connect to the root site collections using cert
Connect-PnPOnline -Url $targeturl -ClientId $clientid -Thumbprint $thumbprint -Tenant $tenant
} else {
# Using service account and password
$serviceAccount = $env:ServiceAccount
$serviceAccountPwd = $env:ServiceAccountPwd
# Connect to the root site collections with the service account
$encPassword = ConvertTo-SecureString -String $serviceAccountPwd -AsPlainText -Force
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $serviceAccount, $encPassword
Connect-PnPOnline -Url $targeturl -Credentials $cred
}
}
# Read the request as a JSON object
$jsoninput = Get-Content $req -Raw | ConvertFrom-Json
# Configure local variable
$targetSiteUrl = $jsoninput.targetSiteUrl
$targetAdminUrl = $jsoninput.targetAdminUrl
$usecert = $jsoninput.usecert
$itemid = $jsoninput.itemId
$targetList = "UPS Sync Jobs"
$targetSiteUrl
# Formatted compressed JSON to be stored in list.
$dataToSync = $jsoninput | ConvertTo-Json -depth 100 -Compress
connectSPOnline -targeturl $targetSiteUrl -usecert $usecert
Set-PnPListItem -List $targetList -Identity $itemid -Values @{"Status" = "In-Progress" } -SystemUpdate
connectSPOnline -targeturl $targetAdminUrl -usecert $usecert
# Iterate the JSON object and update SharePoint User Profile property
foreach ($val in $jsoninput.value) {
if ($null -ne $val.properties) {
foreach ($prop in $val.properties) {
try {
if(($null -ne $prop.value) -and ($prop.value -ne "")) {
if($prop.name -eq "Department") {
Set-PnPUserProfileProperty -Account $val.userid -Property "SPS-Department" -Value $prop.value
}
if($prop.name -eq "Title"){
Set-PnPUserProfileProperty -Account $val.userid -Property "SPS-JobTitle" -Value $prop.value
}
if($prop.name -eq "Office"){
Set-PnPUserProfileProperty -Account $val.userid -Property "SPS-Location" -Value $prop.value
}
Set-PnPUserProfileProperty -Account $val.userid -Property $prop.name -Value $prop.value
if ($null -eq $prop.Status) {
$prop | Add-Member -Name "Status" -Value "Updated" -MemberType NoteProperty
}
else {
$prop.Status = "Updated"
}
}
else {
if ($null -eq $prop.Status) {
$prop | Add-Member -Name "Status" -Value "Not Updated" -MemberType NoteProperty
}
else {
$prop.Status = "Not Updated"
}
}
}
catch {
if ($null -eq $prop.Error) {
$prop | Add-Member -Name "Error" -Value "An error occurred while updating the property!" -MemberType NoteProperty
}
else {
$prop.Error = "An error occurred while updating the property!"
}
}
}
}
}
connectSPOnline -targeturl $targetSiteUrl -usecert $usecert
# JSON after updating the properties of the user
$jsonOutput = $jsoninput | ConvertTo-Json -depth 100 -Compress
Set-PnPListItem -List $targetList -Identity $itemid -Values @{"SyncedData" = $jsonOutput; "Status" = "Completed" } -SystemUpdate

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"spups-propery-sync-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/spupsProperySync/SpupsProperySyncWebPart.js",
"manifest": "./src/webparts/spupsProperySync/SpupsProperySyncWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"SpupsProperySyncWebPartStrings": "lib/webparts/spupsProperySync/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
}
}

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": "react-spupsproperty-sync",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,25 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "React SPUPSproperty Sync",
"id": "42ba670c-573b-46f9-a529-3990cd2a5bc8",
"title": "SPUPSProperty Sync",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false,
"iconPath": "Images/AppIcon.png",
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "User.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.Read.All"
}
]
},
"paths": {
"zippedPackage": "solution/react-spupsproperty-sync.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,8 @@
'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.addSuppression(`Warning - [sass] The local CSS class 'ms-BasePicker-text' is not camelCase and will not be type-safe.`);
build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
{
"name": "react-spupsproperty-sync",
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test",
"serve": "cross-env NODE_OPTIONS=--max_old_space_size=4096 gulp bundle --custom-serve && cross-env NODE_OPTIONS=--max_old_space_size=4096 webpack-dev-server --mode development --config ./webpack.js --env.env=dev"
},
"dependencies": {
"@microsoft/sp-core-library": "1.10.0",
"@microsoft/sp-lodash-subset": "1.10.0",
"@microsoft/sp-office-ui-fabric-core": "1.10.0",
"@microsoft/sp-property-pane": "1.10.0",
"@microsoft/sp-webpart-base": "1.10.0",
"@pnp/common": "^2.0.3",
"@pnp/graph": "^2.0.3",
"@pnp/logging": "^2.0.3",
"@pnp/odata": "^2.0.3",
"@pnp/sp": "^2.0.3",
"@pnp/spfx-controls-react": "^1.16.0",
"@pnp/spfx-property-controls": "^1.17.0",
"@types/es6-promise": "0.0.33",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1",
"csvtojson": "^2.0.10",
"json2csv": "^5.0.0",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"office-ui-fabric-react": "6.189.2",
"react": "16.8.5",
"react-dom": "16.8.5",
"react-editable-table": "^1.12.32"
},
"resolutions": {
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/rush-stack-compiler-3.3": "0.3.5",
"@microsoft/sp-build-web": "1.10.0",
"@microsoft/sp-module-interfaces": "1.10.0",
"@microsoft/sp-tslint-rules": "1.10.0",
"@microsoft/sp-webpart-workbench": "1.10.0",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2",
"cross-env": "7.0.2",
"del": "5.1.0",
"gulp": "~3.9.1",
"jquery": "^3.4.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

View File

@ -0,0 +1,66 @@
export enum FileContentType {
Blob,
Text,
ArrayBuffer,
JSON
}
export enum MessageScope {
Success,
Failure,
Warning,
Info,
Blocked,
SevereWarning
}
export enum SyncType {
Manual = "Manual",
Azure = "Azure",
Template = "Template"
}
export enum JobStatus {
Submitted = "Submitted",
InProgress = "In-Progress",
Completed = "Completed",
CompletedWithError = "Completed With Error",
Error = "Error"
}
export interface IUserInfo {
ID: number;
Email: string;
LoginName: string;
DisplayName: string;
Picture: string;
IsSiteAdmin: boolean;
Groups: string[];
}
export interface IPropertyMappings {
ID: number;
Title: string;
AzProperty: string;
SPProperty: string;
IsActive: boolean;
AutoSync: boolean;
IsIncluded?: boolean;
}
export interface IPropertyPair {
name: string;
value: string;
}
export interface IUserPropertyMapping {
UserID: string;
Properties: IPropertyPair[];
}
export interface IJsonMapping {
targetSiteUrl?: string;
targetAdminUrl?: string;
values?: IUserPropertyMapping[];
}

View File

@ -0,0 +1,362 @@
import { HttpClient, IHttpClientOptions, HttpClientResponse } from '@microsoft/sp-http';
import { sp } from "@pnp/sp";
import "@pnp/sp/webs";
import "@pnp/sp/site-users/web";
import "@pnp/sp/lists/web";
import "@pnp/sp/items/list";
import "@pnp/sp/fields/list";
import "@pnp/sp/views/list";
import "@pnp/sp/profiles";
import "@pnp/sp/search";
import "@pnp/sp/files";
import "@pnp/sp/folders";
import { graph } from "@pnp/graph";
import "@pnp/graph/users";
import * as moment from 'moment/moment';
import { IWeb } from "@pnp/sp/webs";
import { IUserInfo, IPropertyMappings, IPropertyPair, FileContentType, SyncType, JobStatus } from "./IModel";
import { IList } from '@pnp/sp/lists';
import { ChoiceFieldFormatType } from '@pnp/sp/fields/types';
import { IFileAddResult, IFileInfo } from '@pnp/sp/files';
const map: any = require('lodash/map');
const intersection: any = require('lodash/intersection');
const orderBy: any = require('lodash/orderBy');
export interface ISPHelper {
getCurrentUserInfo: () => Promise<IUserInfo>;
checkCurrentUserGroup: (allowedGroups: string[], userGroups: string[]) => boolean;
getAzurePropertyForUsers: (selectFields: string, filterQuery: string) => Promise<any[]>;
getPropertyMappings: () => Promise<any[]>;
getPropertyMappingsTemplate: (propertyMappings: IPropertyMappings[]) => Promise<any>;
addFilesToFolder: (filename: string, fileContent: any) => Promise<IFileAddResult>;
addDataFilesToFolder: (fileContent: any, filename: string) => Promise<IFileAddResult>;
getFileContent: (filepath: string, contentType: FileContentType) => void;
createSyncItem: (syncType: SyncType) => Promise<number>;
updateSyncItem: (itemid: number, inputJson: string) => void;
updateSyncItemStatus: (itemid: number, errMsg: string) => void;
getAllJobs: () => void;
getAllTemplates: () => Promise<IFileInfo[]>;
getAllBulkList: () => Promise<IFileInfo[]>;
runAzFunction: (httpClient: HttpClient, inputData: any, azFuncUrl: string, itemid: number) => void;
}
export default class SPHelper implements ISPHelper {
private SiteURL: string = "";
private SiteRelativeURL: string = "";
private AdminSiteURL: string = "";
private SyncTemplateFilePath: string = "";
private SyncUploadFilePath: string = "";
private SyncJSONFileName: string = `SyncTemplate_${moment().format("MMDDYYYYhhmmss")}.json`;
private SyncCSVFileName: string = `SyncTemplate_${moment().format("MMDDYYYYhhmmss")}.csv`;
private _web: IWeb = null;
private Lst_PropsMapping = 'Sync Properties Mapping';
private Lst_SyncJobs = 'UPS Sync Jobs';
constructor(siteurl: string, tenantname: string, domainname: string, relativeurl: string, libid: string) {
this.SiteURL = siteurl;
this.SiteRelativeURL = relativeurl;
this.AdminSiteURL = `https://${tenantname}-admin.${domainname}`;
this._web = sp.web;
this.getTemplateLibraryInfo(libid);
}
public getTemplateLibraryInfo = async (libid: string) => {
if (libid) {
let libinfo = await this._web.lists.getById(libid).select('Title').get();
this.SyncTemplateFilePath = `/${libinfo.Title}/SyncJobTemplate/`;
this.SyncUploadFilePath = `/${libinfo.Title}/UPSDataToProcess/`;
}
}
/**
* Get the Azure property data for the Users
*/
public getAzurePropertyForUsers = async (selectFields: string, filterQuery: string): Promise<any[]> => {
let users = await graph.users.filter(filterQuery).select(selectFields).get();
return orderBy(users, 'displayName', 'asc');
}
/**
* Get the property mappings from the 'Sync Properties Mapping' list.
*/
public getPropertyMappings = async (): Promise<any[]> => {
return await this._web.lists.getByTitle(this.Lst_PropsMapping).items
.select("ID", "Title", "AzProperty", "SPProperty", "IsActive", "AutoSync")
.filter(`IsActive eq 1`)
.get();
}
/**
* Generated the property mapping json content.
*/
public getPropertyMappingsTemplate = async (propertyMappings: IPropertyMappings[]) => {
if (!propertyMappings) propertyMappings = await this.getPropertyMappings();
let finalJson: string = "";
let propertyPair: any[] = [];
let sampleUser1 = new Object();
let sampleUser2 = new Object();
sampleUser1['UserID'] = "user1@tenantname.onmicrosoft.com";
sampleUser2['UserID'] = "user2@tenantname.onmicrosoft.com";
propertyMappings.map((propsMap: IPropertyMappings) => {
sampleUser1[propsMap.SPProperty] = "";
sampleUser2[propsMap.SPProperty] = "";
});
propertyPair.push(sampleUser1, sampleUser2);
finalJson = JSON.stringify(propertyPair);
return JSON.parse(finalJson);
}
public getPropertyMappingsTemplate1 = async (propertyMappings: IPropertyMappings[]) => {
if (!propertyMappings) propertyMappings = await this.getPropertyMappings();
let finalJson: string = "";
let propertyPair: IPropertyPair[] = [];
propertyMappings.map((propsMap: IPropertyMappings) => {
propertyPair.push({
name: propsMap.SPProperty,
value: ""
});
});
finalJson = `{
"targetAdminUrl": "${this.AdminSiteURL}",
"targetSiteUrl": "${this.SiteURL}",
"values": [
{
"UserID": "userid@tenantname.onmicrosoft.com",
"Properties": ${JSON.stringify(propertyPair)}
}
]
}`;
return JSON.parse(finalJson);
}
/**
* Get the file content as blob based on the file url.
*/
public getFileContent = async (filepath: string, contentType: FileContentType) => {
switch (contentType) {
case FileContentType.Blob:
return await this._web.getFileByServerRelativeUrl(filepath).getBlob();
case FileContentType.ArrayBuffer:
return await this._web.getFileByServerRelativeUrl(filepath).getBuffer();
case FileContentType.Text:
return await this._web.getFileByServerRelativeUrl(filepath).getText();
case FileContentType.JSON:
return await this._web.getFileByServerRelativeUrl(filepath).getJSON();
}
}
/**
* Add the template file to a folder with contents.
* This is used for creating the template json file.
*/
public addFilesToFolder = async (fileContent: any, isCSV: boolean): Promise<IFileAddResult> => {
let filename = (isCSV) ? this.SyncCSVFileName : this.SyncJSONFileName;
await this.checkAndCreateFolder(this.SiteRelativeURL + this.SyncTemplateFilePath);
return await this._web.getFolderByServerRelativeUrl(this.SiteRelativeURL + this.SyncTemplateFilePath)
.files
.add(decodeURI(this.SiteRelativeURL + this.SyncTemplateFilePath + filename), fileContent, true);
}
/**
* Add the data file to a folder with contents.
* This is used for creating the template json file.
*/
public addDataFilesToFolder = async (fileContent: any, filename: string): Promise<IFileAddResult> => {
await this.checkAndCreateFolder(this.SiteRelativeURL + this.SyncUploadFilePath);
return await this._web.getFolderByServerRelativeUrl(this.SiteRelativeURL + this.SyncUploadFilePath)
.files
.add(decodeURI(this.SiteRelativeURL + this.SyncUploadFilePath + filename), fileContent, true);
}
/**
* Check for the template folder, if not creates.
*/
public checkAndCreateFolder = async (folderPath: string) => {
try {
await this._web.getFolderByServerRelativeUrl(folderPath).get();
} catch (err) {
await this._web.folders.add(folderPath);
}
}
/**
* Get current logged in user information.
*/
public getCurrentUserInfo = async (): Promise<IUserInfo> => {
let currentUserInfo = await this._web.currentUser.get();
let currentUserGroups = await this._web.currentUser.groups.get();
return ({
ID: currentUserInfo.Id,
Email: currentUserInfo.Email,
LoginName: currentUserInfo.LoginName,
DisplayName: currentUserInfo.Title,
IsSiteAdmin: currentUserInfo.IsSiteAdmin,
Groups: map(currentUserGroups, 'LoginName'),
Picture: '/_layouts/15/userphoto.aspx?size=S&username=' + currentUserInfo.UserPrincipalName,
});
}
/**
* Check current user is a member of groups or not.
*/
public checkCurrentUserGroup = (allowedGroups: string[], userGroups: string[]): boolean => {
if (userGroups.length > 0) {
let diff: string[] = intersection(allowedGroups, userGroups);
if (diff && diff.length > 0) return true;
}
return false;
}
/**
* Create a sync item
*/
public createSyncItem = async (syncType: SyncType): Promise<number> => {
let returnVal: number = 0;
let itemAdded = await this._web.lists.getByTitle(this.Lst_SyncJobs).items.add({
Title: `SyncJob_${moment().format("MMDDYYYYhhmm")}`,
Status: JobStatus.Submitted.toString(),
SyncType: syncType.toString()
});
returnVal = itemAdded.data.Id;
return returnVal;
}
/**
* Update Sync item with the input data to sync
*/
public updateSyncItem = async (itemid: number, inputJson: string) => {
await this._web.lists.getByTitle(this.Lst_SyncJobs).items.getById(itemid).update({
SyncData: inputJson
});
}
/**
* Update Sync item with the error status
*/
public updateSyncItemStatus = async (itemid: number, errMsg: string) => {
await this._web.lists.getByTitle(this.Lst_SyncJobs).items.getById(itemid).update({
Status: JobStatus.Error,
ErrorMessage: errMsg
});
}
/**
* Get all the jobs items
*/
public getAllJobs = async () => {
return await this._web.lists.getByTitle(this.Lst_SyncJobs).items
.select('ID', 'Title', 'SyncedData', 'Status', 'ErrorMessage', 'SyncType', 'Created', 'Author/Title', 'Author/Id', 'Author/EMail')
.expand('Author')
.getAll();
}
/**
* Get all the templates generated
*/
public getAllTemplates = async (): Promise<IFileInfo[]> => {
return await this._web.getFolderByServerRelativeUrl(this.SiteRelativeURL + this.SyncTemplateFilePath)
.files
.select('Name', 'ServerRelativeUrl', 'TimeCreated')
.expand('Author')
.get();
}
/**
* Get all the bulk sync files
*/
public getAllBulkList = async (): Promise<IFileInfo[]> => {
return await this._web.getFolderByServerRelativeUrl(this.SiteRelativeURL + this.SyncUploadFilePath)
.files
.select('Name', 'ServerRelativeUrl', 'TimeCreated')
.expand('Author')
.get();
}
/**
* Check and create the required lists
*/
public checkAndCreateLists = async (): Promise<boolean> => {
return new Promise<boolean>(async (res, rej) => {
try {
await this._web.lists.getByTitle(this.Lst_PropsMapping).get();
console.log('Property Mapping List Exists');
} catch (err) {
console.log("Property Mapping List doesn't exists, so creating");
await this._createPropsMappingList();
console.log("Property Mapping List created");
}
try {
await this._web.lists.getByTitle(this.Lst_SyncJobs).get();
console.log('Sync Jobs List Exists');
} catch (err) {
console.log("Sync Jobs List doesn't exists, so creating");
await this._createSyncJobsList();
console.log("Sync Jobs List created");
}
console.log("Checked all lists");
res(true);
});
}
/**
* Create Sync Jobs list
*/
public _createSyncJobsList = async () => {
let listExists = await (await sp.web.lists.ensure(this.Lst_SyncJobs)).list;
await listExists.fields.addMultilineText('SyncData', 6, false, false, false, false, { Required: true, Description: 'Data sent to Azure function for property update.' });
await listExists.fields.addMultilineText('SyncedData', 6, false, false, false, false, { Required: true, Description: 'Data received from Azure function with property update status.' });
await listExists.fields.addChoice('Status', ['Submitted', 'In-Progress', 'Completed', 'Error'], ChoiceFieldFormatType.Dropdown, false, { Required: true, Description: 'Status of the job.' });
await listExists.fields.addMultilineText('ErrorMessage', 6, false, false, false, false, { Required: false, Description: 'Store the error message while calling Azure function.' });
await listExists.fields.addChoice('SyncType', ['Manual', 'Azure', 'Template'], ChoiceFieldFormatType.Dropdown, false, { Required: true, Description: 'Type of data sent to Azure function.' });
let allItemsView = await listExists.views.getByTitle('All Items');
let batch = sp.createBatch();
allItemsView.fields.inBatch(batch).add('ID');
allItemsView.fields.inBatch(batch).add('SyncData');
allItemsView.fields.inBatch(batch).add('SyncedData');
allItemsView.fields.inBatch(batch).add('Status');
allItemsView.fields.inBatch(batch).add('ErrorMessage');
allItemsView.fields.inBatch(batch).add('SyncType');
allItemsView.fields.inBatch(batch).move('ID', 0);
await batch.execute();
}
/**
* Create property mapping list
*/
public _createPropsMappingList = async () => {
let listExists = await (await sp.web.lists.ensure(this.Lst_PropsMapping)).list;
await listExists.fields.addText('AzProperty', 255, { Required: true, Description: 'Azure user profile property name.' });
await listExists.fields.addText('SPProperty', 255, { Required: true, Description: 'SharePoint User Profile property name.' });
await listExists.fields.addBoolean('IsActive', { Required: true, Description: 'Active or InActive used for mapping by the end users.' });
await listExists.fields.addBoolean('AutoSync', { Required: true, Description: 'Properties that are automatically synced with Azure.' });
let allItemsView = await listExists.views.getByTitle('All Items');
let batch = sp.createBatch();
allItemsView.fields.inBatch(batch).add('AzProperty');
allItemsView.fields.inBatch(batch).add('SPProperty');
allItemsView.fields.inBatch(batch).add('IsActive');
allItemsView.fields.inBatch(batch).add('AutoSync');
await batch.execute();
await this._createDefaultPropsMapping(listExists);
}
/**
* Create default property mapping items
*/
public _createDefaultPropsMapping = async (lst: IList) => {
let batch = sp.createBatch();
lst.items.inBatch(batch).add({ Title: 'Department', AzProperty: 'department', SPProperty: 'Department', IsActive: true, AutoSync: true });
lst.items.inBatch(batch).add({ Title: 'Job Title', AzProperty: 'jobTitle', SPProperty: 'Title', IsActive: true, AutoSync: true });
lst.items.inBatch(batch).add({ Title: 'Office', AzProperty: 'officeLocation', SPProperty: 'Office', IsActive: true, AutoSync: true });
lst.items.inBatch(batch).add({ Title: 'Business Phone', AzProperty: 'businessPhones', SPProperty: 'workPhone', IsActive: true, AutoSync: false });
lst.items.inBatch(batch).add({ Title: 'Mobile Phone', AzProperty: 'mobilePhone', SPProperty: 'CellPhone', IsActive: true, AutoSync: false });
lst.items.inBatch(batch).add({ Title: 'Fax Number', AzProperty: 'faxNumber', SPProperty: 'Fax', IsActive: true, AutoSync: false });
lst.items.inBatch(batch).add({ Title: 'Street Address', AzProperty: 'streetAddress', SPProperty: 'StreetAddress', IsActive: true, AutoSync: false });
lst.items.inBatch(batch).add({ Title: 'City', AzProperty: 'city', SPProperty: 'City', IsActive: true, AutoSync: false });
lst.items.inBatch(batch).add({ Title: 'State or Province', AzProperty: 'state', SPProperty: 'State', IsActive: true, AutoSync: false });
lst.items.inBatch(batch).add({ Title: 'Zip or Postal code', AzProperty: 'postalCode', SPProperty: 'PostalCode', IsActive: true, AutoSync: false });
lst.items.inBatch(batch).add({ Title: 'Country or Region', AzProperty: 'country', SPProperty: 'Country', IsActive: true, AutoSync: false });
await batch.execute();
}
/**
* Azure function to update the UPS properties.
*/
public runAzFunction = async (httpClient: HttpClient, inputData: any, azFuncUrl: string, itemid: number) => {
const requestHeaders: Headers = new Headers();
requestHeaders.append("Content-type", "application/json");
requestHeaders.append("Cache-Control", "no-cache");
const postOptions: IHttpClientOptions = {
headers: requestHeaders,
body: `${inputData}`
};
let response: HttpClientResponse = await httpClient.post(azFuncUrl, HttpClient.configurations.v1, postOptions);
if (!response.ok) {
await this.updateSyncItemStatus(itemid, `${response.status} - ${response.statusText}`);
}
console.log("Azure Function executed");
}
}

View File

@ -0,0 +1,52 @@
$ms-greenLight: "[theme:greenLight, default:#bad80a]";
$ms-neutralSecondaryAlt: "[theme:info, default:#767676]";
$ms-neutralLight: "[theme:infoBackground, default:#eaeaea]";
$ms-magenta: "[theme:magenta, default:#b4009e]";
$ms-magentaDark: "[theme:magentaDark, default:#5c005c]";
$ms-magentaLight: "[theme:magentaLight, default:#e3008c]";
$ms-neutralDark: "[theme:neutralDark, default:#212121]";
$ms-neutralLight: "[theme:neutralLight, default:#eaeaea]";
$ms-neutralLighter: "[theme:neutralLighter, default:#f4f4f4]";
$ms-neutralLighterAlt: "[theme:neutralLighterAlt, default:#f8f8f8]";
$ms-neutralPrimary: "[theme:neutralPrimary, default:#333333]";
$ms-neutralPrimaryAlt: "[theme:neutralPrimaryAlt, default:#3C3C3C]";
$ms-neutralQuaternary: "[theme:neutralPrimaryTranslucent50, default:#d0d0d0]";
$ms-neutralQuaternaryAlt: "[theme:neutralQuaternary, default:#dadada]";
$ms-neutralSecondary: "[theme:neutralQuaternaryAlt, default:#666666]";
$ms-neutralSecondaryAlt: "[theme:neutralSecondary, default:#767676]";
$ms-neutralTertiary: "[theme:neutralSecondaryAlt, default:#a6a6a6]";
$ms-neutralTertiaryAlt: "[theme:neutralTertiary, default:#c8c8c8]";
$ms-white: "[theme:neutralTertiaryAlt, default:#ffffff]";
$ms-orange: "[theme:orange, default:#d83b01]";
$ms-orangeLight: "[theme:orangeLight, default:#ea4300]";
$ms-orangeLighter: "[theme:orangeLighter, default:#ff8c00]";
$ms-primaryBackground: "[theme:primaryBackground, default:#0078d7]";
$ms-primaryText: "[theme:primaryText, default:#0078d7]";
$ms-purple: "[theme:purple, default:#5c2d91]";
$ms-purpleDark: "[theme:purpleDark, default:#32145a]";
$ms-purpleLight: "[theme:purpleLight, default:#b4a0ff]";
$ms-red: "[theme:red, default:#e81123]";
$ms-redDark: "[theme:redDark, default:#a80000]";
$ms-success: "[theme:success, default:#107c10]";
$ms-successBackground: "[theme:successBackground, default:#dff6dd]";
$ms-teal: "[theme:teal, default:#008272]";
$ms-tealDark: "[theme:tealDark, default:#004b50]";
$ms-tealLight: "[theme:tealLight, default:#00b294]";
$ms-themeAccent: "[theme:themeAccent, default:inherit]";
$ms-themeAccentTranslucent10: "[theme:themeAccentTranslucent10, default:inherit]";
$ms-themeDark: "[theme:themeDark, default:#005a9e]";
$ms-themeDarkAlt: "[theme:themeDarkAlt, default:#106ebe]";
$ms-themeDarker: "[theme:themeDarker, default:#004578]";
$ms-themeLight: "[theme:themeLight, default:#b3d6f2]";
$ms-themeLightAlt: "[theme:themeLightAlt, default:inherit]";
$ms-themeLighter: "[theme:themeLighter]";
$ms-themeLighterAlt: "[theme:themeLighterAlt, default:#eff6fc]";
$ms-themePrimary: "[theme:themePrimary]";
$ms-themeSecondary: "[theme:themeSecondary]";
$ms-themeTertiary: "[theme:themeTertiary]";
$ms-themeTertiaryAlt: "[theme:themeTertiaryAlt, default:#c8c8c8]";
$ms-white: "[theme:white, default:#ffffff]";
$ms-whiteTranslucent40: "[theme:whiteTranslucent40, default:rgba(255,255,255,.4)]";
$ms-yellow: "[theme:yellow, default:#ffb900]";
$ms-yellowLight: "[theme:yellowLight, default:#fff100]";
$ms-error: "[theme:error]";

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,19 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "f137ab5e-5484-4c5e-9344-01eb54f065ec",
"alias": "SpupsProperySyncWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart", "SharePointFullPage"],
"supportsFullBleed": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Other" },
"title": { "default": "SPUPSPropery Sync" },
"description": { "default": "Sync Azure AD properties or User defined values (custom properties) to SharePoint User Profile Properties." },
"officeFabricIconFontName": "UserSync",
"properties": {}
}]
}

View File

@ -0,0 +1,221 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version, Environment, EnvironmentType } from '@microsoft/sp-core-library';
import {
IPropertyPaneConfiguration,
PropertyPaneTextField,
IPropertyPanePage
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart, WebPartContext } from '@microsoft/sp-webpart-base';
import { CalloutTriggers } from '@pnp/spfx-property-controls/lib/PropertyFieldHeader';
import { PropertyFieldLabelWithCallout } from '@pnp/spfx-property-controls/lib/PropertyFieldLabelWithCallout';
import { PropertyFieldToggleWithCallout } from '@pnp/spfx-property-controls/lib/PropertyFieldToggleWithCallout';
import { PropertyFieldListPicker, PropertyFieldListPickerOrderBy } from '@pnp/spfx-property-controls/lib/PropertyFieldListPicker';
import { PropertyPaneWebPartInformation } from '@pnp/spfx-property-controls/lib/PropertyPaneWebPartInformation';
import { PropertyFieldPeoplePicker, PrincipalType, IPropertyFieldGroupOrPerson } from '@pnp/spfx-property-controls/lib/PropertyFieldPeoplePicker';
import { sp } from '@pnp/sp';
import { graph } from "@pnp/graph";
import * as strings from 'SpupsProperySyncWebPartStrings';
import SpupsProperySync from './components/SpupsProperySync';
import { ISpupsProperySyncProps } from './components/SpupsProperySync';
import SPHelper from '../../Common/SPHelper';
import { IUserInfo } from '../../Common/IModel';
export interface ISpupsProperySyncWebPartProps {
context: WebPartContext;
templateLib: string;
appTitle: string;
AzFuncUrl: string;
UseCert: boolean;
dateFormat: string;
toggleInfoHeaderValue: boolean;
useFullWidth: boolean;
allowedUsers: IPropertyFieldGroupOrPerson[];
}
export default class SpupsProperySyncWebPart extends BaseClientSideWebPart<ISpupsProperySyncWebPartProps> {
private loadingIndicator: boolean = true;
private wpPropertyPages: IPropertyPanePage[] = [];
protected async onInit() {
await super.onInit();
sp.setup(this.context);
graph.setup({ spfxContext: this.context });
}
public render(): void {
const element: React.ReactElement<ISpupsProperySyncProps> = React.createElement(
SpupsProperySync,
{
context: this.context,
templateLib: this.properties.templateLib,
displayMode: this.displayMode,
appTitle: this.properties.appTitle,
AzFuncUrl: this.properties.AzFuncUrl,
UseCert: this.properties.UseCert,
dateFormat: this.properties.dateFormat ? this.properties.dateFormat : "DD, MMM YYYY hh:mm A",
allowedUsers: this.properties.allowedUsers,
useFullWidth: this.properties.useFullWidth,
updateProperty: (value: string) => {
this.properties.appTitle = value;
},
openPropertyPane: this.openPropertyPane
}
);
ReactDom.render(element, this.domElement);
}
protected get disableReactivePropertyChanges() {
return true;
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
private openPropertyPane = (): void => {
this.context.propertyPane.open();
}
private getUserWPProperties = (): IPropertyPanePage[] => {
return [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneWebPartInformation({
description: `${strings.PropInfoNormalUser}`,
key: 'normalUserInfoId'
}),
]
}
]
}
];
}
private getAdminWPProperties = (): IPropertyPanePage[] => {
return [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyFieldListPicker('templateLib', {
key: 'templateLibFieldId',
label: strings.PropTemplateLibLabel,
selectedList: this.properties.templateLib,
includeHidden: false,
orderBy: PropertyFieldListPickerOrderBy.Title,
disabled: false,
onPropertyChange: this.onPropertyPaneFieldChanged.bind(this),
properties: this.properties,
context: this.context,
onGetErrorMessage: null,
deferredValidationTime: 0,
baseTemplate: 101,
listsToExclude: ['Documents']
}),
PropertyPaneWebPartInformation({
description: `${strings.PropInfoTemplateLib}`,
key: 'templateLibInfoId'
}),
PropertyPaneTextField('AzFuncUrl', {
label: strings.PropAzFuncLabel,
description: strings.PropAzFuncDesc,
multiline: true,
placeholder: strings.PropAzFuncLabel,
resizable: true,
rows: 5,
value: this.properties.AzFuncUrl
}),
PropertyFieldToggleWithCallout('UseCert', {
calloutTrigger: CalloutTriggers.Hover,
key: 'UseCertFieldId',
label: strings.PropUseCertLabel,
calloutContent: React.createElement('div', {}, strings.PropUseCertCallout),
onText: 'ON',
offText: 'OFF',
checked: this.properties.UseCert
}),
PropertyPaneWebPartInformation({
description: `${strings.PropInfoUseCert}`,
key: 'useCertInfoId'
}),
PropertyPaneTextField('dateFormat', {
label: strings.PropDateFormatLabel,
description: '',
multiline: false,
placeholder: strings.PropDateFormatLabel,
resizable: false,
value: this.properties.dateFormat
}),
PropertyPaneWebPartInformation({
description: `${strings.PropInfoDateFormat}`,
key: 'dateFormatInfoId'
}),
PropertyFieldPeoplePicker('allowedUsers', {
label: 'SharePoint Groups',
initialData: this.properties.allowedUsers,
allowDuplicate: false,
principalType: [PrincipalType.SharePoint],
onPropertyChange: this.onPropertyPaneFieldChanged,
context: this.context,
properties: this.properties,
onGetErrorMessage: null,
deferredValidationTime: 0,
key: 'allowedUsersFieldId'
}),
PropertyPaneWebPartInformation({
description: `${strings.PropAllowedUserInfo}`,
key: 'allowedUsersInfoId'
}),
PropertyFieldToggleWithCallout('useFullWidth', {
key: 'useFullWidthFieldId',
label: 'Use page full width',
onText: 'ON',
offText: 'OFF',
checked: this.properties.useFullWidth
}),
]
}
]
}
];
}
protected async onPropertyPaneConfigurationStart() {
this.context.statusRenderer.displayLoadingIndicator(this.domElement, 'Loading properties...');
let helper = new SPHelper(this.context.pageContext.legacyPageContext.siteAbsoluteUrl,
this.context.pageContext.legacyPageContext.tenantDisplayName,
this.context.pageContext.legacyPageContext.webDomain,
this.context.pageContext.web.serverRelativeUrl,
''
);
let currentUserInfo: IUserInfo = await helper.getCurrentUserInfo();
if (currentUserInfo.IsSiteAdmin)
this.wpPropertyPages = this.getAdminWPProperties();
else this.wpPropertyPages = this.getUserWPProperties();
this.context.propertyPane.refresh();
this.context.statusRenderer.clearLoadingIndicator(this.domElement);
this.render();
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: this.wpPropertyPages
};
}
}

View File

@ -0,0 +1,67 @@
import * as React from 'react';
import * as strings from 'SpupsProperySyncWebPartStrings';
import styles from './SpupsProperySync.module.scss';
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import { MessageScope } from '../../../Common/IModel';
import EditableTable from './DynamicTable/EditableTable';
import MessageContainer from './MessageContainer';
export interface IAzurePropertyViewProps {
userProperties: any;
showProgress: boolean;
UpdateSPUserWithAzureProps: (data: any) => void;
}
export interface IAzurePropertyViewState {
data: any;
}
export default class AzurePropertyView extends React.Component<IAzurePropertyViewProps, IAzurePropertyViewState> {
constructor(props: IAzurePropertyViewProps) {
super(props);
this.state = {
data: []
};
}
public componentDidMount = async () => {
this.setState({ data: this.props.userProperties });
}
public componentDidUpdate = (prevProps: IAzurePropertyViewProps) => {
if (prevProps.userProperties !== this.props.userProperties) {
this.setState({ data: this.props.userProperties });
}
}
private handleRowDel = (item) => {
var index = this.state.data.indexOf(item);
this.state.data.splice(index, 1);
this.setState(this.state.data);
}
private updateWithAzureProperty = () => {
this.props.UpdateSPUserWithAzureProps(this.state.data);
}
public render(): JSX.Element {
const { data } = this.state;
return (
<div>
{(data && data.length > 0) ? (
<>
<EditableTable onRowDel={this.handleRowDel.bind(this)} data={data} isReadOnly={true} />
<div style={{ marginTop: "5px" }}>
<PrimaryButton text={strings.BtnUpdateUserProps} onClick={this.updateWithAzureProperty} style={{ marginRight: '5px' }} disabled={this.props.showProgress} />
{this.props.showProgress && <Spinner className={styles.generateTemplateLoader} label={strings.PropsUpdateLoader} ariaLive="assertive" labelPosition="right" />}
</div>
</>
) : (
<div><MessageContainer MessageScope={MessageScope.Info} Message={strings.EmptyTable} /></div>
)
}
</div>
);
}
}

View File

@ -0,0 +1,102 @@
import * as React from 'react';
import styles from '../SpupsProperySync.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { DetailsList, IColumn, DetailsListLayoutMode, ConstrainMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList';
import { IPersonaSharedProps, Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import * as csv from 'csvtojson';
import SPHelper from '../../../../Common/SPHelper';
import { FileContentType } from '../../../../Common/IModel';
export interface IBulkSyncDataProps {
helper: SPHelper;
fileurl: string;
}
export default function BulkSyncData(props: IBulkSyncDataProps) {
const [loading, setLoading] = React.useState<boolean>(true);
const [filedata, setFileData] = React.useState<any[]>([]);
const [columns, setColumns] = React.useState<IColumn[]>([]);
const _buildColumns = (colValues: string[]) => {
let cols: IColumn[] = [];
colValues.map(col => {
if (col.toLowerCase() == "userid") {
cols.push({
key: 'UserID', name: 'User ID', fieldName: col, minWidth: 250, maxWidth: 250,
onRender: (item: any, index: number, column: IColumn) => {
const authorPersona: IPersonaSharedProps = {
imageUrl: `/_layouts/15/userphoto.aspx?Size=S&Username=${item[col]}`,
text: item[col],
};
return (
<div><Persona {...authorPersona} size={PersonaSize.size24} /></div>
);
}
} as IColumn);
} else {
cols.push({
key: col, name: col, fieldName: col, minWidth: 150,
onRender: (item: any, index: number, column: IColumn) => {
if (item[col]) {
return (<div>{item[col]}</div>);
} else {
return (<div className={styles.emptyData}>{strings.EmptyDataText}</div>);
}
}
} as IColumn);
}
});
setColumns(cols);
};
const _getJSONData = (inputjson?: any) => {
let parsedJson = (inputjson) ? inputjson : JSON.parse(inputjson);
let _dynamicColumns: string[] = [];
Object.keys(parsedJson[0]).map((key) => {
_dynamicColumns.push(key);
});
_buildColumns(_dynamicColumns);
setFileData(parsedJson);
setLoading(false);
};
const _buildBulkSyncDataList = async () => {
if (props.fileurl) {
let fileextn: string = props.fileurl.split('.').pop();
let filecontent: any = null;
if (fileextn.toLowerCase() === "csv") {
filecontent = await props.helper.getFileContent(props.fileurl, FileContentType.Text);
let finalOut: any = await csv().fromString(filecontent);
_getJSONData(finalOut);
}
else {
filecontent = await props.helper.getFileContent(props.fileurl, FileContentType.JSON);
_getJSONData(filecontent);
}
}
};
React.useEffect(() => {
_buildBulkSyncDataList();
}, [props.fileurl]);
return (
<div style={{ maxHeight: '600', maxWidth: '600', overflow: 'auto' }}>
{loading &&
<Spinner size={SpinnerSize.small} label={strings.BulkSyncFileDataLoaderDesc} labelPosition={"top"} />
}
{!loading && filedata && filedata.length > 0 &&
<DetailsList
items={filedata}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true}
className={styles.uppropertylist} />
}
</div>
);
}

View File

@ -0,0 +1,207 @@
import * as React from 'react';
import styles from '../SpupsProperySync.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { DetailsList, IColumn, DetailsListLayoutMode, ConstrainMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList';
import { IPersonaSharedProps, Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox';
import { ActionButton, IconButton } from 'office-ui-fabric-react/lib/Button';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog';
import { Link } from 'office-ui-fabric-react/lib/Link';
import { Icon, IconType, IIconProps } from 'office-ui-fabric-react/lib/Icon';
import * as moment from 'moment/moment';
import { MessageScope } from '../../../../Common/IModel';
import SPHelper from '../../../../Common/SPHelper';
import MessageContainer from '../MessageContainer';
import BulkSyncData from './BulkSyncData';
const orderBy: any = require('lodash/orderBy');
const filter: any = require('lodash/filter');
export interface IBulkSyncListProps {
helper: SPHelper;
siteurl: string;
dateFormat: string;
}
export default function BulkSyncList(props: IBulkSyncListProps) {
const actionIcon: IIconProps = { iconName: 'InfoSolid' };
const refreshIcon: IIconProps = { iconName: 'Refresh' };
const [refreshLoading, setRefreshLoading] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<boolean>(true);
const [downloadLoading, setDownloadLoading] = React.useState<boolean>(false);
const [bulklist, setBulkList] = React.useState<any[]>([]);
const [filteredBulkList, setFilteredBulkList] = React.useState<any[]>([]);
const [columns, setColumns] = React.useState<IColumn[]>([]);
const [searchKey, setSearchKey] = React.useState<string>('');
const [emptySearch, setEmptySearch] = React.useState<boolean>(false);
const [hideDialog, setHideDialog] = React.useState<boolean>(true);
const [fileurl, setFileUrl] = React.useState<string>('');
const downloadTemplate = async (fileserverurl, filename) => {
setDownloadLoading(true);
const anchor = window.document.createElement('a');
anchor.href = `${props.siteurl}/_layouts/15/download.aspx?SourceUrl=${fileserverurl}`;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setDownloadLoading(false);
};
const actionClick = async (data) => {
if (data.FileUrl) {
setFileUrl(data.FileUrl);
setHideDialog(false);
}
};
const ActionRender = (actionProps) => {
return (
<ActionButton iconProps={actionIcon} allowDisabledFocus onClick={() => { actionClick(actionProps); }} />
);
};
const _buildColumns = () => {
let cols: IColumn[] = [];
cols.push({
key: 'Name', name: 'Name', fieldName: 'Name', minWidth: 300, maxWidth: 300,
onRender: (item: any, index: number, column: IColumn) => {
let fileextn = item.Name.split('.').pop();
return (
<div style={{display: 'flex', overflow: 'hidden', textOverflow: 'ellipsis'}}>
<div className={styles.fileiconDiv}>
{fileextn.toLowerCase() === "csv" &&
<Icon iconName="ExcelDocument" ariaLabel={item.Name} iconType={IconType.Default} />
}
{fileextn.toLowerCase() === "json" &&
<Icon iconName="FileCode" ariaLabel={item.Name} iconType={IconType.Default} />
}
</div>
<Link onClick={() => { downloadTemplate(`${item.ServerRelativeUrl}`, `${item.Name}`); }} value={item.Name}>{item.Name}</Link>
{downloadLoading &&
<div className={styles.downloadLoaderDiv}>
<Spinner size={SpinnerSize.small} />
</div>
}
</div>
);
}
});
cols.push({
key: 'Author', name: 'Author', fieldName: 'Author', minWidth: 300, maxWidth: 300,
onRender: (item: any, index: number, column: IColumn) => {
const authorPersona: IPersonaSharedProps = {
imageUrl: `/_layouts/15/userphoto.aspx?Size=S&Username=${item["Author"].Email}`,
text: item["Author"].Title,
};
return (
<div><Persona {...authorPersona} size={PersonaSize.size24} /></div>
);
}
} as IColumn);
cols.push({
key: 'TimeCreated', name: 'Created', fieldName: 'TimeCreated', minWidth: 100, maxWidth: 200,
onRender: (item: any, index: number, column: IColumn) => {
return (
<div>{moment(item.TimeCreated).format(props.dateFormat)}</div>
);
}
} as IColumn);
cols.push({
key: 'Actions', name: 'Actions', fieldName: 'ID', minWidth: 100, maxWidth: 100,
onRender: (item: any, index: number, column: IColumn) => {
return (<ActionRender FileUrl={item.ServerRelativeUrl} />);
}
});
setColumns(cols);
};
const _loadBulkSyncList = async () => {
let bulkSyncList = await props.helper.getAllBulkList();
bulkSyncList = orderBy(bulkSyncList, ['TimeCreated'], ['desc']);
setBulkList(bulkSyncList);
};
const _buildBulkSyncList = async () => {
_buildColumns();
await _loadBulkSyncList();
setLoading(false);
};
const _refreshList = async () => {
setRefreshLoading(true);
await _loadBulkSyncList();
setRefreshLoading(false);
};
const _searchBulkSyncList = (srchkey) => {
setEmptySearch(false);
setSearchKey(srchkey);
let filteredList = filter(bulklist, (o) => {
return o.Name.toLowerCase().indexOf(srchkey.toLowerCase()) >= 0 || o['Author'].Title.toLowerCase().indexOf(srchkey.toLowerCase()) >= 0;
});
if (filteredList.length <= 0) setEmptySearch(true);
setFilteredBulkList(filteredList);
};
const _closeDialog = () => {
setHideDialog(true);
};
React.useEffect(() => {
_buildBulkSyncList();
}, [props.dateFormat]);
return (
<div className={styles.syncjobsContainer}>
{loading &&
<ProgressIndicator label={strings.PropsLoader} description={strings.BulkSyncListLoaderDesc} />
}
{(!loading && bulklist && bulklist.length > 0) ? (
<>
<div className={styles.searchcontainer}>
<SearchBox placeholder={strings.TemplateListSearchPH} underlined={true} value={searchKey} onChange={_searchBulkSyncList} />
</div>
<div className={styles.refreshContainer}>
<IconButton iconProps={refreshIcon} title="Refresh" ariaLabel="Refresh" onClick={_refreshList} disabled={refreshLoading} />
{refreshLoading &&
<Spinner size={SpinnerSize.small} />
}
</div>
{emptySearch &&
<div style={{ marginTop: '-10px', width: '95%' }}>
<MessageContainer MessageScope={MessageScope.Failure} Message={strings.EmptySearchResults} />
</div>
}
<div className={styles.templateList}>
<DetailsList
items={filteredBulkList && filteredBulkList.length > 0 ? filteredBulkList : bulklist}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true}
className={styles.uppropertylist} />
</div>
</>
) : (
<>
{!loading &&
<MessageContainer MessageScope={MessageScope.Info} Message={strings.EmptyTable} />
}
</>
)
}
<Dialog hidden={hideDialog} onDismiss={_closeDialog} maxWidth='700' minWidth='500px'
dialogContentProps={{
type: DialogType.close,
title: `${strings.BulkSyncDataDialogTitle}`
}}
modalProps={{
isBlocking: true,
isDarkOverlay: true,
styles: { main: { minWidth: 500, maxHeight: 700, minHeight: 100 } },
}}>
<BulkSyncData helper={props.helper} fileurl={fileurl} />
</Dialog>
</div >
);
}

View File

@ -0,0 +1,72 @@
import * as React from 'react';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
import styles from './DynamicTable.module.scss';
import EditableCell from './EditableCell';
export interface IDataRowProps {
item: any;
columns: any;
isReadOnly?: boolean;
onTableUpdate: () => void;
onDelRow: (item: any) => void;
}
export default function DataRow(props: IDataRowProps) {
function onDelEvent() {
props.onDelRow(props.item);
}
return (
<tr>
{props.isReadOnly ? (
<>
{props.columns.map(col => {
if (col.toLocaleLowerCase() !== "imageurl" && col.toLocaleLowerCase() !== "userprincipalname" && col.toLocaleLowerCase() !== "id") {
if (col.toLocaleLowerCase() == "displayname") {
return <EditableCell cellData={{
"type": col,
value: props.item.displayName,
id: props.item.userPrincipalName,
label: true,
ImageUrl: props.item.ImageUrl
}} isReadOnly={props.isReadOnly} />;
} else {
return <EditableCell cellData={{
"type": col,
value: props.item[col],
id: props.item.userPrincipalName,
}} isReadOnly={props.isReadOnly} />;
}
}
})}
</>
) : (
<>
{props.columns.map(col => {
if (col.toLocaleLowerCase() !== "imageurl" && col.toLocaleLowerCase() !== "displayname") {
if (col == "UserID") {
return <EditableCell onTableUpdate={props.onTableUpdate} cellData={{
"type": col,
value: props.item.DisplayName,
id: props.item.UserID,
label: true,
ImageUrl: props.item.ImageUrl
}} />;
} else {
return <EditableCell onTableUpdate={props.onTableUpdate} cellData={{
"type": col,
value: props.item[col],
id: props.item.UserID,
label: false
}} />;
}
}
})}
</>
)}
<td>
<IconButton iconProps={{ iconName: "UserRemove" }} title="Remove" ariaLabel="Remove" onClick={onDelEvent} />
</td>
</tr>
);
}

View File

@ -0,0 +1,47 @@
.dynamicTable {
overflow: auto;
.table {
margin-top: 10px;
border: 1px solid #CCC;
width: 100%;
thead > tr > th {
padding: 5px;
background-color: lightgray;
}
td:first-child {
width: 20%;
}
}
.textInput {
padding: 8px;
font-size: 12px;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: 0px 1px 15px 0px #ccc;
width: 93%;
}
.divusername {
width: 100%;
img {
width: 24px;
border-radius: 50%;
}
label {
padding-left: 3px;
vertical-align: top;
white-space: nowrap;
width: 80%;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
span {
// white-space: nowrap;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
word-break: break-word;
}
}
}

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import styles from './DynamicTable.module.scss';
export interface IEditableCellProps {
cellData: any;
onTableUpdate?: (event: any) => void;
isReadOnly?: boolean;
}
export interface IEditableCellState {
inputtext: string;
}
export default class EditableCell extends React.Component<IEditableCellProps, IEditableCellState> {
constructor(props: IEditableCellProps) {
super(props);
this.state = {
inputtext: ""
};
}
private handleTextChange = (e) => {
this.setState({ inputtext: e.target.value });
this.props.onTableUpdate(e);
}
public render(): JSX.Element {
const { cellData, isReadOnly } = this.props;
return (
<td>
{isReadOnly ? (
<>
{!cellData.label ? (
<div>{cellData.value ? cellData.value : " - "}</div>
) : (
<div className={styles.divusername}>
<img src={`/_layouts/15/userphoto.aspx?accountname=${cellData.id}&size=M`} />
<label>{cellData.value}</label>
</div>
)}
</>
) : (
<>
{!cellData.label ? (
<input type='text' className={styles.textInput} name={cellData.type} id={cellData.id} value={this.state.inputtext} onChange={this.handleTextChange} />
) : (
<div className={styles.divusername}>
<img src={cellData.ImageUrl} />
<label>{cellData.value}</label>
</div>
)}
</>
)
}
</td>
);
}
}

View File

@ -0,0 +1,50 @@
import * as React from 'react';
import styles from './DynamicTable.module.scss';
import DataRow from './DataRow';
export interface IEditableTableProps {
onTableUpdate?: () => void;
onRowDel: () => void;
filterText?: string;
data: any[];
isReadOnly?: boolean;
}
export default function EditableTable(props: IEditableTableProps) {
var keyData = JSON.parse(JSON.stringify(props.data));
var columns = Object.keys(keyData[0]);
var rowitem: any = props.data.map((item) => {
return (<DataRow item={item} columns={columns} onTableUpdate={props.onTableUpdate} onDelRow={props.onRowDel} key={item.UserID} isReadOnly={props.isReadOnly} />);
});
return (
<div className={styles.dynamicTable}>
<table className={styles.table}>
<thead>
<tr>
{props.isReadOnly ? (
<>
{columns.map(key => {
if (key.toLocaleLowerCase() !== "imageurl" && key.toLocaleLowerCase() !== "userprincipalname" && key.toLocaleLowerCase() !== "id") {
return (<th>{key}</th>);
}
})}
</>
) : (
<>
{columns.map(key => {
if (key.toLocaleLowerCase() !== "imageurl" && key.toLocaleLowerCase() !== "displayname") {
return (<th>{key}</th>);
}
})}
</>
)}
</tr>
</thead>
<tbody>
{rowitem}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,87 @@
import * as React from 'react';
import * as strings from 'SpupsProperySyncWebPartStrings';
import styles from './SpupsProperySync.module.scss';
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import EditableTable from './DynamicTable/EditableTable';
import MessageContainer from './MessageContainer';
import { MessageScope } from '../../../Common/IModel';
export interface IManualPropertyUpdateProps {
userProperties: any;
showProgress: boolean;
UpdateSPUserWithManualProps: (data: any) => void;
}
export interface IManualPropertyUpdateState {
data: any;
}
export default class ManualPropertyUpdate extends React.Component<IManualPropertyUpdateProps, IManualPropertyUpdateState> {
constructor(props: IManualPropertyUpdateProps) {
super(props);
this.state = {
data: []
};
}
public componentDidMount = async () => {
this.setState({ data: this.props.userProperties });
}
public componentDidUpdate = (prevProps: IManualPropertyUpdateProps) => {
if (prevProps.userProperties !== this.props.userProperties) {
this.setState({ data: this.props.userProperties });
}
}
private handleRowDel = (item) => {
var index = this.state.data.indexOf(item);
this.state.data.splice(index, 1);
this.setState(this.state.data);
}
private handlePropertyTable = (evt) => {
var newProp = {
id: evt.target.id,
name: evt.target.name,
value: evt.target.value
};
var upProperties = this.state.data.slice();
var newitem = upProperties.map((item) => {
for (var key in item) {
if (key == newProp.name && item.UserID == newProp.id) {
item[key] = newProp.value;
}
}
return item;
});
this.setState({ data: newitem });
}
private updateWithManualProperty = () => {
this.props.UpdateSPUserWithManualProps(this.state.data);
}
public render(): JSX.Element {
const { data } = this.state;
const { showProgress } = this.props;
return (
<div>
{(data && data.length > 0) ? (
<>
<EditableTable onTableUpdate={this.handlePropertyTable.bind(this)} onRowDel={this.handleRowDel.bind(this)}
data={data} />
<div style={{ marginTop: "5px" }}>
<PrimaryButton text={strings.BtnUpdateUserProps} onClick={this.updateWithManualProperty} style={{ marginRight: '5px' }} disabled={showProgress} />
{showProgress && <Spinner className={styles.generateTemplateLoader} label={strings.PropsUpdateLoader} ariaLive="assertive" labelPosition="right" />}
</div>
</>
) : (
<div><MessageContainer MessageScope={MessageScope.Info} Message={strings.EmptyTable} /></div>
)
}
</div>
);
}
}

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import styles from './SpupsProperySync.module.scss';
import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import { Text } from 'office-ui-fabric-react/lib/Text';
import { MessageScope } from '../../../Common/IModel';
export interface IMessageContainerProps {
Message?: string;
MessageScope: MessageScope;
ShowDismiss?: boolean;
}
export default function MessageContainer(props: IMessageContainerProps) {
const [showMessage, setshowMessage] = React.useState<boolean>(true);
const dismissMessage = () => {
setshowMessage(false);
};
const dismiss = props.ShowDismiss ? dismissMessage : null;
return (
<div className={styles.MessageContainer}>
{
props.MessageScope === MessageScope.Success && showMessage &&
<MessageBar messageBarType={MessageBarType.success} onDismiss={dismiss}>
<Text block variant={"mediumPlus"}>{props.Message}</Text>
</MessageBar>
}
{
props.MessageScope === MessageScope.Failure && showMessage &&
<MessageBar messageBarType={MessageBarType.error} onDismiss={dismiss}>
<Text block variant={"mediumPlus"}>{props.Message}</Text>
</MessageBar>
}
{
props.MessageScope === MessageScope.Warning && showMessage &&
<MessageBar messageBarType={MessageBarType.warning} onDismiss={dismiss}>
<Text block variant={"mediumPlus"}>{props.Message}</Text>
</MessageBar>
}
{
props.MessageScope === MessageScope.Info && showMessage &&
<MessageBar messageBarType={MessageBarType.info} className={styles.infoMessage} onDismiss={dismiss}>
<Text block variant={"mediumPlus"}>{props.Message}</Text>
</MessageBar>
}
{
props.MessageScope === MessageScope.Blocked && showMessage &&
<MessageBar messageBarType={MessageBarType.blocked} onDismiss={dismiss}>
<Text block variant={"mediumPlus"}>{props.Message}</Text>
</MessageBar>
}
{
props.MessageScope === MessageScope.SevereWarning && showMessage &&
<MessageBar messageBarType={MessageBarType.severeWarning} onDismiss={dismiss}>
<Text block variant={"mediumPlus"}>{props.Message}</Text>
</MessageBar>
}
</div>
);
}

View File

@ -0,0 +1,61 @@
@import '../../../../Common/theming';
.propertyMappingPanelContent {
.mappingcontainer {
display: inline-block;
width: 100%;
}
.propertytitlediv {
display: inline-block;
width: 25%;
padding: 0px;
margin-bottom: 10px;
text-align: center;
font-weight: bold;
}
.togglediv {
width: 20%;
text-align: center;
display: inline-block;
margin-left: 20px;
}
.propertydiv {
display: inline-block;
width: 25%;
box-shadow: 0 2px 4px 0 rgba(0,0,0,.2), 0 0px 0px 0 rgba(0,0,0,.1);
padding: 10px 3px;
border-radius: 5px;
margin-bottom: 10px;
text-align: center;
border: 1px solid;
border-color: $ms-themeTertiary;
}
.separator {
display: inline-block;
width: 25%;
i {
margin-top: 5px;
}
}
.separator::before {
background-color: $ms-themeLighter;
}
}
.panelHeader {
margin-top: -29px;
padding: 10px;
border-bottom: 1px dotted;
font-weight: 700;
}
.panelFooter {
button {
margin-right: 10px;
}
}
.generateTemplateLoader {
display: inline-flex;
margin-top: 5px;
}
.propertyMappingList {
padding: 0px;
}

View File

@ -0,0 +1,47 @@
import * as React from 'react';
import { Separator } from 'office-ui-fabric-react/lib/Separator';
import { Icon, IIconStyles } from 'office-ui-fabric-react/lib/Icon';
import { Toggle } from 'office-ui-fabric-react/lib/Toggle';
import styles from './PropertyMapping.module.scss';
import { IPropertyMappings } from '../../../../Common/IModel';
const iconStyles: IIconStyles = {
root: {
fontSize: '24px',
height: '24px',
width: '24px'
}
};
export interface IPropertyMappingItemProps {
items: IPropertyMappings[];
onEnableOrDisableProperty: (item: IPropertyMappings, checked: boolean) => void;
}
export default class PropertyMappingItem extends React.Component<IPropertyMappingItemProps, {}> {
constructor(props: IPropertyMappingItemProps) {
super(props);
}
public render(): JSX.Element {
const { items } = this.props;
return (
<>
{items.map((item: IPropertyMappings) => {
return (
<div className={styles.mappingcontainer} data-is-focusable={true}>
<div className={styles.propertydiv}>{item.AzProperty}</div>
<Separator className={styles.separator}>
<Icon iconName="DoubleChevronRight8" styles={iconStyles} />
</Separator>
<div className={styles.propertydiv}>{item.SPProperty}</div>
<div className={styles.togglediv}>
<Toggle label="" checked={item.IsIncluded} onChange={(e, checked) => { this.props.onEnableOrDisableProperty(item, checked); }} />
</div>
</div>
);
})}
</>
);
}
}

View File

@ -0,0 +1,202 @@
import * as React from 'react';
import styles from './PropertyMapping.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { IIconProps } from 'office-ui-fabric-react/lib/Icon';
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import SPHelper from '../../../../Common/SPHelper';
import { IPropertyMappings, MessageScope } from '../../../../Common/IModel';
import PropertyMappingItem from './PropertyMappingItem';
import { parse } from 'json2csv';
import MessageContainer from '../MessageContainer';
const filter: any = require('lodash/filter');
const downloadIcon: IIconProps = { iconName: 'SaveTemplate' };
const csvIcon: IIconProps = { iconName: 'FileTemplate' };
export interface IPropertyMappingProps {
mappingProperties: IPropertyMappings[];
helper: SPHelper;
disabled: boolean;
siteurl: string;
}
export interface IPropertyMappingState {
isOpen: boolean;
templateProperties: IPropertyMappings[];
downloadLink: string;
templateFileName: string;
showProgress: boolean;
disableButtons: boolean;
disableMappingButton: boolean;
globalMessage: string;
globalMessageScope?: MessageScope;
}
export default class PropertyMappingList extends React.Component<IPropertyMappingProps, IPropertyMappingState> {
private includedProperties: IPropertyMappings[] = [];
/**
* Default constructor
* @param props
*/
constructor(props: IPropertyMappingProps) {
super(props);
this.state = {
isOpen: false,
templateProperties: [],
downloadLink: '',
templateFileName: '',
showProgress: false,
disableButtons: false,
disableMappingButton: false,
globalMessage: ""
};
}
/**
* Component mount
*/
public componentDidMount = () => {
let templateProperties: IPropertyMappings[] = this.getDefaultTemplateProperties();
this.setState({ templateProperties });
}
/**
* Component updated
*/
public componentDidUpdate = (prevProps: IPropertyMappingProps) => {
if (prevProps.mappingProperties !== this.props.mappingProperties ||
prevProps.disabled !== this.props.disabled) {
let templateProperties: IPropertyMappings[] = this.getDefaultTemplateProperties();
this.setState({
templateProperties,
disableMappingButton: this.props.disabled
});
}
}
/**
* Get the property mappings from the props
*/
private getDefaultTemplateProperties = () => {
let defaultProps: IPropertyMappings[] = this.props.mappingProperties;
let globalMessage: string = "";
if (defaultProps.length <= 0) globalMessage = strings.EmptyPropertyMappings;
else globalMessage = "";
this.setState({ globalMessage, globalMessageScope: MessageScope.Failure, disableButtons: globalMessage.length > 0, disableMappingButton: globalMessage.length > 0 });
return defaultProps;
}
/**
* Update the property mappings state by enabling or disabling the property
* Based on this the templates will be generated
*/
private _onEnableOrDisableProperty = (item: IPropertyMappings, checked: boolean) => {
let templateProperties: IPropertyMappings[] = this.state.templateProperties;
let property = templateProperties.filter(prop => { return prop.ID == item.ID; });
if (property) property[0].IsIncluded = false;
this.setState({ ...this.state, templateProperties });
this.render();
}
/**
* Get the default property mappings and then open the panel
*/
private _openPropertyMappingPanel = () => {
let templateProperties = this.getDefaultTemplateProperties();
this.setState({ templateProperties, isOpen: true });
}
/**
* Dismiss or close the panel
*/
private _dismissPanel = () => {
this.setState({ isOpen: false });
}
/**
* Custom panel footer contents with buttons
*/
private _onRenderPanelFooterContent = (): JSX.Element => {
return (
<div className={styles.panelFooter}>
<PrimaryButton iconProps={downloadIcon} onClick={this._generateJSONTemplate} disabled={this.state.disableButtons}>{strings.BtnGenerateJSON}</PrimaryButton>
<PrimaryButton iconProps={csvIcon} onClick={this._generateCSVTemplate} disabled={this.state.disableButtons}>{strings.BtnGenerateCSV}</PrimaryButton>
{this.state.showProgress && <Spinner className={styles.generateTemplateLoader} label={strings.GenerateTemplateLoader} ariaLive="assertive" labelPosition="right" />}
</div>
);
}
/**
* Get the property mappings that are included by the user
*/
private _getIncludedPropertyMapping = () => {
return filter(this.state.templateProperties, (o) => { return o.IsIncluded; });
}
/**
* Button click to generate the JSON template
*/
private _generateJSONTemplate = async () => {
this.setState({ disableButtons: true, showProgress: true });
const { helper } = this.props;
let jsonOut = await helper.getPropertyMappingsTemplate(this._getIncludedPropertyMapping());
let fileTemplate = await helper.addFilesToFolder(JSON.stringify(jsonOut), false);
this.setState({
downloadLink: fileTemplate.data.ServerRelativeUrl,
templateFileName: fileTemplate.data.Name
}, this.getTemplateFile);
}
/**
* Button click to generate the CSV template
*/
private _generateCSVTemplate = async () => {
this.setState({ disableButtons: true, showProgress: true });
const { helper } = this.props;
let templateProperties = this._getIncludedPropertyMapping();
let fields: string[] = [];
fields.push("UserID");
templateProperties.map(propmap => {
fields.push(propmap.SPProperty);
});
const csv = parse("", { fields });
let fileTemplate = await helper.addFilesToFolder(csv, true);
this.setState({
downloadLink: fileTemplate.data.ServerRelativeUrl,
templateFileName: fileTemplate.data.Name
}, this.getTemplateFile);
}
/**
* Download the JSON file
*/
private getTemplateFile = async () => {
const anchor = window.document.createElement('a');
anchor.href = `${this.props.siteurl}/_layouts/15/download.aspx?SourceUrl=${this.state.downloadLink}`;
anchor.download = this.state.templateFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
this.setState({ disableButtons: false, showProgress: false, globalMessage: strings.TemplateDownloaded, globalMessageScope: MessageScope.Success });
}
/**
* Component render
*/
public render(): JSX.Element {
const { isOpen, templateProperties, disableMappingButton, globalMessage, globalMessageScope } = this.state;
return (
<div className={styles.propertyMappingList}>
<PrimaryButton text={strings.BtnPropertyMapping} onClick={this._openPropertyMappingPanel} disabled={disableMappingButton} />
<Panel isOpen={isOpen} onDismiss={this._dismissPanel} type={PanelType.largeFixed} closeButtonAriaLabel="Close" headerText={strings.PnlHeaderText}
headerClassName={styles.panelHeader} isFooterAtBottom={true} onRenderFooterContent={this._onRenderPanelFooterContent}>
<div className={styles.propertyMappingPanelContent}>
{globalMessage.length > 0 &&
<div style={{ marginTop: '10px', marginBottom: '10px' }}>
<MessageContainer MessageScope={globalMessageScope} Message={globalMessage} />
</div>
}
<div className={styles.mappingcontainer} data-is-focusable={true} style={{ marginBottom: '10px' }}>
<div className={styles.propertytitlediv}>{strings.TblColHeadAzProperty}</div>
<div className={styles.separator}>&nbsp;</div>
<div className={styles.propertytitlediv}>{strings.TblColHeadSPProperty}</div>
<div className={styles.propertytitlediv} style={{ padding: '0px' }}>{strings.TblColHeadEnableDisable}</div>
</div>
<PropertyMappingItem items={templateProperties} onEnableOrDisableProperty={this._onEnableOrDisableProperty} />
</div>
</Panel>
</div>
);
}
}

View File

@ -0,0 +1,147 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
//@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
@import '../../../Common/theming';
.spupsProperySync {
.container {
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);
}
div.ms-BasePicker-text {
border: none !important;
}
.row {
@include ms-Grid-row;
padding: 20px;
}
.column {
@include ms-Grid-col;
@include ms-lg10;
@include ms-xl10;
@include ms-xlPush2;
@include ms-lgPush1;
}
}
.periodmenu {
margin-bottom: 15px !important;
width: 85%;
display: inline-block;
}
.menuContent {
padding: 8px;
}
.emptyData {
color: $ms-error;
font-weight: bold;
}
.generateTemplateLoader {
display: inline-flex;
margin-top: 5px;
}
.status {font-weight: bold;}
.status.blue { i { color: blue; } }
.status.green { i { color: green; } }
.status.orange { i { color: orange; } }
.status.red { i { color: red; } }
.resultsIconSpan {
top: 7px;
position: absolute;
margin-left: 5px;
}
.searchcontainer {
width: 95%;
display: inline-block;
}
.refreshContainer {
display: inline-flex;
margin-top: 10px;
margin-left: 5px;
position: absolute;
}
.syncjobsContainer {
max-height: 500px;
overflow-y: auto;
button {
height: 24px;
}
}
.templatesContainer {
max-height: 500px;
button {
height: 24px;
}
div.templateList {
overflow-y: auto;
height: 450px;
margin-top: 5px;
width: 100%;
}
}
.uppropertydata {
.uppropertylist {
overflow-x: auto;
}
}
.downloadLoaderDiv {
width: auto;
display: inline-block;
margin-left: 10px;
position: absolute;
margin-top: 5px;
}
.fileiconDiv {
width: 5%;
display: inline-block;
i {
position: absolute;
top: 13px;
}
}
.propertyMappingContainer {
display: inline-block;
width: 100%;
text-align: center;
.propertydiv {
display: inline-block;
width: 25%;
box-shadow: 0 2px 4px 0 rgba(0,0,0,.2), 0 0px 0px 0 rgba(0,0,0,.1);
padding: 10px 3px;
border-radius: 5px;
margin-bottom: 10px;
margin-right: 10px;
text-align: center;
border: 1px solid;
border-color: $ms-themeTertiary;
}
}
.MessageContainer{
font-style: italic;
font-weight: bold !important;
padding-top: 10px;
padding-bottom: 10px;
.errorMessage{
color: red !important;
padding-top: 10px !important;
text-align: center;
}
.successMessage{
color: #64BE1A !important;
padding-top: 10px !important;
text-align: center;
}
.warningMessage{
color: #BEBB1A !important;
padding-top: 10px !important;
text-align: center;
}
.infoMessage{
background-color: rgb(148, 210, 230) !important;
}
}

View File

@ -0,0 +1,588 @@
import * as React from 'react';
import styles from './SpupsProperySync.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { DisplayMode } from '@microsoft/sp-core-library';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
import { PeoplePicker, PrincipalType } from "@pnp/spfx-controls-react/lib/PeoplePicker";
import { IPropertyFieldGroupOrPerson } from '@pnp/spfx-property-controls/lib/propertyFields/peoplePicker/IPropertyFieldPeoplePicker';
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import { FilePicker, IFilePickerResult } from '@pnp/spfx-controls-react/lib/FilePicker';
import { FileTypeIcon, IconType } from "@pnp/spfx-controls-react/lib/FileTypeIcon";
import { Pivot, PivotItem } from 'office-ui-fabric-react/lib/Pivot';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import { css, ProgressIndicator } from 'office-ui-fabric-react/lib';
import { IPropertyMappings, FileContentType, MessageScope, SyncType } from '../../../Common/IModel';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import SPHelper from '../../../Common/SPHelper';
import PropertyMappingList from './PropertyMapping/PropertyMappingList';
import UPPropertyData from './UPPropertyData';
import ManualPropertyUpdate from './ManualPropertyUpdate';
import AzurePropertyView from './AzurePropertyView';
import SyncJobsView from './SyncJobs/SyncJobs';
import TemplatesView from './TemplatesList/TemplatesView';
import BulkSyncList from './BulkSyncFiles/BulkSyncList';
import * as moment from 'moment/moment';
import MessageContainer from './MessageContainer';
const map: any = require('lodash/map');
export interface ISpupsProperySyncProps {
context: WebPartContext;
templateLib: string;
displayMode: DisplayMode;
appTitle: string;
AzFuncUrl: string;
UseCert: boolean;
dateFormat: string;
allowedUsers: IPropertyFieldGroupOrPerson[];
useFullWidth: boolean;
openPropertyPane: () => void;
updateProperty: (value: string) => void;
}
export interface ISpupsProperySyncState {
listExists: boolean;
isSiteAdmin: boolean;
loading: boolean;
accessDenied: boolean;
propertyMappings: IPropertyMappings[];
uploadedTemplate?: IFilePickerResult;
uploadedFileURL?: string;
showUploadData: boolean;
showUploadProgress: boolean;
showPropsLoader: boolean;
updatePropsLoader_Manual: boolean;
updatePropsLoader_Azure: boolean;
updatePropsLoader_Bulk: boolean;
clearData: boolean;
disablePropsButtons: boolean;
uploadedData?: any;
isCSV: boolean;
selectedUsers?: any[];
manualPropertyData: any[];
azurePropertyData: any[];
reloadGetProperties: boolean;
helper: SPHelper;
selectedMenu?: string;
globalMessage: string;
noActivePropertyMappings: boolean;
}
export default class SpupsProperySync extends React.Component<ISpupsProperySyncProps, ISpupsProperySyncState> {
// Private variables
private helper: SPHelper = null;
/**
* Constructor
* @param props
*/
constructor(props: ISpupsProperySyncProps) {
super(props);
this.state = {
listExists: false,
isSiteAdmin: false,
loading: true,
accessDenied: false,
propertyMappings: [],
showUploadData: false,
showUploadProgress: false,
showPropsLoader: false,
updatePropsLoader_Manual: false,
updatePropsLoader_Azure: false,
updatePropsLoader_Bulk: false,
clearData: false,
disablePropsButtons: false,
isCSV: false,
selectedUsers: [],
manualPropertyData: [],
azurePropertyData: [],
reloadGetProperties: false,
helper: null,
selectedMenu: '0',
globalMessage: '',
noActivePropertyMappings: true
};
}
/**
* Component mount
*/
public componentDidMount = async () => {
this._useFullWidth();
this.initializeHelper();
let currentUserInfo = await this.helper.getCurrentUserInfo();
if (currentUserInfo.IsSiteAdmin) {
this.setState({ isSiteAdmin: true });
this._checkAndCreateLists();
} else {
let allowedGroups: string[] = map(this.props.allowedUsers, 'login');
let accessAllowed: boolean = this.helper.checkCurrentUserGroup(allowedGroups, currentUserInfo.Groups);
console.log(accessAllowed);
if (accessAllowed) {
this._checkAndCreateLists();
} else {
this.setState({ loading: false, accessDenied: true });
}
}
}
/**
* Component update
*/
public componentDidUpdate = (prevProps: ISpupsProperySyncProps) => {
if (prevProps.templateLib !== this.props.templateLib) this.initializeHelper();
//if (prevProps.appTitle !== this.props.appTitle || prevProps.dateFormat !== this.props.dateFormat || this.props.allowedUsers) this.render();
if (prevProps.useFullWidth !== this.props.useFullWidth) this._useFullWidth();
}
/**
* Check and create the required list
*/
public _checkAndCreateLists = async () => {
this.setState({ loading: false });
let listExists = await this.helper.checkAndCreateLists();
if (listExists) {
let propertyMappings: IPropertyMappings[] = await this.helper.getPropertyMappings();
let globalMessage: string = "";
let noActivePropertyMappings: boolean = true;
if (propertyMappings.length <= 0) {
globalMessage = strings.EmptyPropertyMappings;
noActivePropertyMappings = true;
} else {
globalMessage = "";
noActivePropertyMappings = false;
}
propertyMappings.map(prop => { prop.IsIncluded = true; });
this.setState({ listExists, propertyMappings, globalMessage, noActivePropertyMappings, disablePropsButtons: noActivePropertyMappings });
}
}
/**
* Initialize the helper with required arguments.
*/
private initializeHelper = () => {
this.helper = new SPHelper(this.props.context.pageContext.legacyPageContext.siteAbsoluteUrl,
this.props.context.pageContext.legacyPageContext.tenantDisplayName,
this.props.context.pageContext.legacyPageContext.webDomain,
this.props.context.pageContext.web.serverRelativeUrl,
this.props.templateLib
);
this.setState({ helper: this.helper });
}
/**
* Use full width
*/
private _useFullWidth = () => {
if (this.props.useFullWidth) {
const jQuery: any = require('jquery');
jQuery("#workbenchPageContent").prop("style", "max-width: none");
jQuery(".SPCanvas-canvas").prop("style", "max-width: none");
jQuery(".CanvasZone").prop("style", "max-width: none");
}
}
/**
* Triggers when the users are selected for manual update
*/
private _getPeoplePickerItems = (items: any[]) => {
let reloadGetProperties: boolean = false;
if (this.state.selectedUsers.length > items.length) {
if (this.state.manualPropertyData.length > 0 || this.state.azurePropertyData.length > 0) {
reloadGetProperties = true;
}
}
this.setState({ selectedUsers: items, reloadGetProperties, clearData: false }, () => {
if (this.state.selectedUsers.length <= 0) {
this.state.manualPropertyData.length > 0 ? this._getManualPropertyTable() : this._getAzurePropertyTable();
}
});
}
/**
* Set the defaultusers property for people picker control, this is used when clearing the data.
*/
private _getSelectedUsersLoginNames = (items: any[]): string[] => {
let retUsers: string[] = [];
retUsers = map(items, (o) => { return o.loginName.split('|')[2]; });
return retUsers;
}
/**
* Display the inline editing table to edit the properties for manual update
*/
private _getManualPropertyTable = () => {
this.setState({ disablePropsButtons: true, showPropsLoader: true });
const { propertyMappings, selectedUsers } = this.state;
let includedProperties: IPropertyMappings[] = propertyMappings.filter((o) => { return o.IsIncluded; });
let manualPropertyData: any[] = [];
if (selectedUsers && selectedUsers.length > 0) {
selectedUsers.map(user => {
let userObj = new Object();
userObj['UserID'] = user.loginName;
userObj['DisplayName'] = user.text;
userObj['ImageUrl'] = user.imageUrl;
includedProperties.map((propsMap: IPropertyMappings) => {
userObj[propsMap.SPProperty] = "";
});
manualPropertyData.push(userObj);
});
this.setState({ manualPropertyData, azurePropertyData: [], showPropsLoader: false, disablePropsButtons: false });
} else {
this.setState({ disablePropsButtons: false, showPropsLoader: false, manualPropertyData: [] });
}
}
/**
* Get the property values from Azure
*/
private _getAzurePropertyTable = async () => {
this.setState({ disablePropsButtons: true, showPropsLoader: true });
const { propertyMappings, selectedUsers } = this.state;
let includedProperties: IPropertyMappings[] = propertyMappings.filter((o) => { return o.IsIncluded; });
let selectFields: string = "id, userPrincipalName, displayName, " + map(includedProperties, 'AzProperty').join(',');
let tempQuery: string[] = []; let filterQuery: string = ``;
if (selectedUsers && selectedUsers.length > 0) {
selectedUsers.map(user => {
tempQuery.push(`userPrincipalName eq '${user.loginName.split('|')[2]}'`);
});
filterQuery = tempQuery.join(' or ');
let azurePropertyData = await this.helper.getAzurePropertyForUsers(selectFields, filterQuery);
this.setState({ azurePropertyData, manualPropertyData: [], showPropsLoader: false, disablePropsButtons: false });
} else {
this.setState({ disablePropsButtons: false, showPropsLoader: false, azurePropertyData: [] });
}
}
/**
* On selecting the data file for update
*/
private _onSaveTemplate = (uploadedTemplate: IFilePickerResult) => {
this.setState({ uploadedTemplate, showUploadData: true, clearData: false });
}
/**
* On changing the data file for update
*/
private _onChangeTemplate = (uploadedTemplate: IFilePickerResult) => {
this.setState({ uploadedTemplate, showUploadData: true, clearData: false });
}
/**
* Uploading data file and displaying the contents of the file
*/
private _uploadDataToSync = async () => {
this.setState({ showUploadProgress: true });
const { uploadedTemplate } = this.state;
let filecontent: any = null;
if (uploadedTemplate && uploadedTemplate.fileName) {
let ext: string = uploadedTemplate.fileName.split('.').pop();
let filename: string = `${uploadedTemplate.fileNameWithoutExtension}_${moment().format("MMDDYYYYHHmmss")}.${ext}`;
if (uploadedTemplate.fileAbsoluteUrl && null !== uploadedTemplate.fileAbsoluteUrl) {
let filerelativeurl: string = "";
if (uploadedTemplate.fileAbsoluteUrl.indexOf(this.props.context.pageContext.legacyPageContext.webAbsoluteUrl) >= 0) {
filerelativeurl = uploadedTemplate.fileAbsoluteUrl.replace(this.props.context.pageContext.legacyPageContext.webAbsoluteUrl,
this.props.context.pageContext.legacyPageContext.webServerRelativeUrl);
}
filecontent = await this.helper.getFileContent(filerelativeurl, FileContentType.Blob);
await this.helper.addDataFilesToFolder(filecontent, filename);
if (ext.toLocaleLowerCase() == "csv") {
filecontent = await this.helper.getFileContent(filerelativeurl, FileContentType.Text);
} else if (ext.toLocaleLowerCase() == "json") {
filecontent = await this.helper.getFileContent(filerelativeurl, FileContentType.JSON);
}
this.setState({ showUploadProgress: false, uploadedData: filecontent, isCSV: ext.toLocaleLowerCase() == "csv" });
} else {
let dataToSync = await uploadedTemplate.downloadFileContent();
let filereader = new FileReader();
filereader.readAsBinaryString(dataToSync);
filereader.onload = async () => {
let dataUploaded = await this.helper.addDataFilesToFolder(filereader.result, filename);
if (ext.toLocaleLowerCase() == "csv") {
filecontent = await dataUploaded.file.getText();
} else if (ext.toLocaleLowerCase() == "json") {
filecontent = await dataUploaded.file.getJSON();
}
this.setState({ showUploadProgress: false, uploadedData: filecontent, isCSV: ext.toLocaleLowerCase() == "csv" });
};
}
}
}
/**
* Update with manual properties
*/
private _updateSPWithManualProperties = async (data: any[]) => {
this.setState({ updatePropsLoader_Manual: true });
let itemID = await this.helper.createSyncItem(SyncType.Manual);
let finalJson = this._prepareJSONForAzFunc(data, false, itemID);
await this.helper.updateSyncItem(itemID, finalJson);
this.helper.runAzFunction(this.props.context.httpClient, finalJson, this.props.AzFuncUrl, itemID);
this.setState({ updatePropsLoader_Manual: false, clearData: true, selectedUsers: [], manualPropertyData: [] });
}
/**
* Update with azure properties
*/
private _updateSPWithAzureProperties = async (data: any[]) => {
this.setState({ updatePropsLoader_Azure: true });
let itemID = await this.helper.createSyncItem(SyncType.Azure);
let finalJson = this._prepareJSONForAzFunc(data, true, itemID);
await this.helper.updateSyncItem(itemID, finalJson);
this.helper.runAzFunction(this.props.context.httpClient, finalJson, this.props.AzFuncUrl, itemID);
this.setState({ updatePropsLoader_Azure: false, clearData: true, selectedUsers: [], azurePropertyData: [] });
}
/**
* Update with csv or json file
*/
private _updateSPForBulkUsers = async (data: any[]) => {
this.setState({ updatePropsLoader_Bulk: true });
let itemID = await this.helper.createSyncItem(SyncType.Template);
let finalJson = this._prepareJSONForAzFunc(data, false, itemID);
await this.helper.updateSyncItem(itemID, finalJson);
this.helper.runAzFunction(this.props.context.httpClient, finalJson, this.props.AzFuncUrl, itemID);
this.setState({ updatePropsLoader_Bulk: false, clearData: true, uploadedData: null, uploadedTemplate: null, uploadedFileURL: '', showUploadData: false });
}
/**
* Prepare JSON based on the manual or az data to call AZ FUNC.
*/
private _prepareJSONForAzFunc = (data: any[], isAzure: boolean, itemid: number): string => {
let finalJson: string = "";
if (data && data.length > 0) {
let userPropMapping = new Object();
userPropMapping['targetSiteUrl'] = this.props.context.pageContext.legacyPageContext.webAbsoluteUrl;
userPropMapping['targetAdminUrl'] = `https://${this.props.context.pageContext.legacyPageContext.tenantDisplayName}-admin.${this.props.context.pageContext.legacyPageContext.webDomain}`;
userPropMapping['usecert'] = this.props.UseCert ? this.props.UseCert : false;
userPropMapping['itemId'] = itemid;
let propValues: any[] = [];
data.map((userprop: any) => {
let userPropValue: any = {};
let userProperties: any[] = [];
let userPropertiesKeys: string[] = Object.keys(userprop);
userPropertiesKeys.map((prop: string) => {
if (isAzure && prop.toLowerCase() == "userprincipalname") {
userPropValue['userid'] = userprop[prop].indexOf('|') > 0 ? userprop[prop].split('|')[2] : userprop[prop];
}
if (!isAzure && prop.toLowerCase() == "userid") {
userPropValue['userid'] = userprop[prop].indexOf('|') > 0 ? userprop[prop].split('|')[2] : userprop[prop];
}
if (prop.toLowerCase() !== "userid" && prop.toLowerCase() !== "id" && prop.toLowerCase() !== "displayname"
&& prop.toLowerCase() !== "userprincipalname" && prop.toLowerCase() !== "imageurl") {
let objProp = new Object();
objProp['name'] = isAzure ? this._getSPPropertyName(prop) : prop;
objProp['value'] = userprop[prop];
userProperties.push(JSON.parse(JSON.stringify(objProp)));
}
});
userPropValue['properties'] = JSON.parse(JSON.stringify(userProperties));
propValues.push(JSON.parse(JSON.stringify(userPropValue)));
});
userPropMapping['value'] = propValues;
finalJson = JSON.stringify(userPropMapping);
}
return finalJson;
}
/**
* Get SPProperty name for Azure Property
*/
private _getSPPropertyName = (azPropName: string): string => {
return this.state.propertyMappings.filter((o) => { return o.AzProperty.toLowerCase() === azPropName.toLowerCase(); })[0].SPProperty;
}
/**
* On menu click
*/
private _onMenuClick = (item?: PivotItem, ev?: React.MouseEvent<HTMLElement, MouseEvent>): void => {
if (item) {
if (item.props.itemKey == "0") {
this.setState({
updatePropsLoader_Manual: false, updatePropsLoader_Azure: false, clearData: false, selectedUsers: [],
manualPropertyData: [], azurePropertyData: []
});
} else if (item.props.itemKey == "1") {
this.setState({ uploadedData: null, uploadedTemplate: null, uploadedFileURL: '', showUploadData: false });
}
this.setState({
selectedMenu: item.props.itemKey
}, () => {
});
}
}
/**
* Component render
*/
public render(): React.ReactElement<ISpupsProperySyncProps> {
const { templateLib, displayMode, appTitle, AzFuncUrl } = this.props;
const { propertyMappings, uploadedTemplate, uploadedFileURL, showUploadData, showUploadProgress, uploadedData, isCSV, selectedUsers, manualPropertyData,
azurePropertyData, disablePropsButtons, showPropsLoader, reloadGetProperties, selectedMenu, updatePropsLoader_Manual, updatePropsLoader_Azure,
updatePropsLoader_Bulk, clearData, globalMessage, noActivePropertyMappings, listExists, isSiteAdmin, loading, accessDenied } = this.state;
const fileurl = uploadedFileURL ? uploadedFileURL : uploadedTemplate && uploadedTemplate.fileAbsoluteUrl ? uploadedTemplate.fileAbsoluteUrl :
uploadedTemplate && uploadedTemplate.fileName ? uploadedTemplate.fileName : '';
const showConfig = !templateLib || !AzFuncUrl ? true : false;
const headerButtonProps = { 'disabled': showUploadProgress || updatePropsLoader_Manual || updatePropsLoader_Azure || updatePropsLoader_Bulk };
return (
<div className={styles.spupsProperySync}>
<div className={styles.container}>
<div className={styles.row}>
<div className={styles.column}>
<WebPartTitle displayMode={displayMode} title={appTitle ? appTitle : strings.DefaultAppTitle} updateProperty={this.props.updateProperty} />
{showConfig ? (
<>
{isSiteAdmin ? (
<Placeholder iconName='DataManagementSettings'
iconText={strings.PlaceholderIconText}
description={strings.PlaceholderDescription}
buttonLabel={strings.PlaceholderButtonLabel}
hideButton={displayMode === DisplayMode.Read}
onConfigure={this.props.openPropertyPane} />
) : (
<>
{loading &&
<ProgressIndicator label={strings.SitePrivilegeCheckLabel} description={strings.PropsLoader} />
}
{!loading &&
<MessageContainer MessageScope={MessageScope.SevereWarning} Message={strings.AdminConfigHelp} />
}
</>
)}
</>
) : (
<>
{loading ? (
<ProgressIndicator label={strings.AccessCheckDesc} description={strings.PropsLoader} />
) : (
<>
{accessDenied ? (
<MessageContainer MessageScope={MessageScope.SevereWarning} Message={strings.AccessDenied} />
) : (
<>
{!listExists ? (
<ProgressIndicator label={strings.ListCreationText} description={strings.PropsLoader} />
) : (
<>
<div>
{globalMessage.length > 0 &&
<div style={{ marginTop: '10px', marginBottom: '10px' }}>
<MessageContainer MessageScope={MessageScope.Failure} Message={globalMessage} />
</div>
}
<Pivot defaultSelectedKey="0" selectedKey={selectedMenu} onLinkClick={this._onMenuClick} className={styles.periodmenu}>
<PivotItem headerText={strings.TabMenu1} itemKey="0" itemIcon="SchoolDataSyncLogo" headerButtonProps={headerButtonProps} ></PivotItem>
<PivotItem headerText={strings.TabMenu2} itemKey="1" itemIcon="BulkUpload" headerButtonProps={headerButtonProps}></PivotItem>
<PivotItem headerText={strings.TabMenu3} itemKey="2" itemIcon="StackIndicator" headerButtonProps={headerButtonProps}></PivotItem>
<PivotItem headerText={strings.TabMenu4} itemKey="3" itemIcon="FileTemplate" headerButtonProps={headerButtonProps}></PivotItem>
<PivotItem headerText={strings.TabMenu5} itemKey="4" itemIcon="SyncStatus" headerButtonProps={headerButtonProps}></PivotItem>
</Pivot>
<div style={{ float: "right" }}>
<PropertyMappingList mappingProperties={propertyMappings} helper={this.state.helper} siteurl={this.props.context.pageContext.web.serverRelativeUrl}
disabled={showUploadProgress || updatePropsLoader_Manual || updatePropsLoader_Azure || updatePropsLoader_Bulk || noActivePropertyMappings} />
</div>
</div>
{selectedMenu == "0" &&
<div className={css(styles.menuContent)}>
<PeoplePicker
disabled={disablePropsButtons || updatePropsLoader_Manual || updatePropsLoader_Azure}
context={this.props.context}
titleText={strings.PPLPickerTitleText}
personSelectionLimit={10}
groupName={""} // Leave this blank in case you want to filter from all users
showtooltip={false}
isRequired={false}
selectedItems={this._getPeoplePickerItems}
showHiddenInUI={false}
principalTypes={[PrincipalType.User]}
resolveDelay={500}
defaultSelectedUsers={selectedUsers.length > 0 ? this._getSelectedUsersLoginNames(selectedUsers) : []} />
{reloadGetProperties ? (
<>
{selectedUsers.length > 0 &&
<div>
<MessageContainer MessageScope={MessageScope.Info} Message={strings.UserListChanges} />
</div>
}
{selectedUsers.length <= 0 && !clearData &&
<div>
<MessageContainer MessageScope={MessageScope.Info} Message={strings.UserListEmpty} ShowDismiss={true} />
</div>
}
</>
) : (
<></>
)
}
{selectedUsers && selectedUsers.length > 0 &&
<div style={{ marginTop: "5px" }}>
<PrimaryButton text={strings.BtnManualProps} onClick={this._getManualPropertyTable} style={{ marginRight: '5px' }}
disabled={disablePropsButtons || updatePropsLoader_Manual || updatePropsLoader_Azure} />
<PrimaryButton text={strings.BtnAzureProps} onClick={this._getAzurePropertyTable}
disabled={disablePropsButtons || updatePropsLoader_Manual || updatePropsLoader_Azure} />
{showPropsLoader && <Spinner className={styles.generateTemplateLoader} label={strings.PropsLoader} ariaLive="assertive" labelPosition="right" />}
</div>
}
{manualPropertyData && manualPropertyData.length > 0 &&
<ManualPropertyUpdate userProperties={manualPropertyData} UpdateSPUserWithManualProps={this._updateSPWithManualProperties}
showProgress={updatePropsLoader_Manual} />
}
{azurePropertyData && azurePropertyData.length > 0 &&
<AzurePropertyView userProperties={azurePropertyData} UpdateSPUserWithAzureProps={this._updateSPWithAzureProperties}
showProgress={updatePropsLoader_Azure} />
}
{clearData &&
<div><MessageContainer MessageScope={MessageScope.Success} Message={strings.JobIntializedSuccess} /></div>
}
</div>
}
{selectedMenu == "1" &&
<div className={css(styles.menuContent)}>
<div>
<FilePicker
accepts={[".json", ".csv"]}
buttonIcon="FileImage"
onSave={this._onSaveTemplate}
onChanged={this._onChangeTemplate}
context={this.props.context}
disabled={showUploadProgress || updatePropsLoader_Bulk || noActivePropertyMappings}
buttonLabel={"Select Data file"}
hideLinkUploadTab={true}
hideOrganisationalAssetTab={true}
hideWebSearchTab={true}
/>
</div>
{fileurl &&
<div style={{ color: "black", padding: '10px' }}>
<FileTypeIcon type={IconType.font} path={fileurl} />&nbsp;{uploadedTemplate.fileName}
</div>
}
{showUploadData &&
<div style={{ padding: '10px', width: 'auto', display: 'inline-block' }}>
<PrimaryButton text={strings.BtnUploadDataForSync} onClick={this._uploadDataToSync} disabled={showUploadProgress || updatePropsLoader_Bulk} />
{showUploadProgress &&
<div style={{ paddingLeft: '10px', display: 'inline-block' }}><Spinner className={styles.generateTemplateLoader} label={strings.UploadDataToSyncLoader} ariaLive="assertive" labelPosition="right" /></div>
}
</div>
}
<UPPropertyData items={uploadedData} isCSV={isCSV} UpdateSPForBulkUsers={this._updateSPForBulkUsers} showProgress={updatePropsLoader_Bulk}
clearData={clearData} />
{clearData &&
<div><MessageContainer MessageScope={MessageScope.Success} Message={strings.JobIntializedSuccess} /></div>
}
</div>
}
{selectedMenu == "2" &&
<div className={css(styles.menuContent)}>
<BulkSyncList helper={this.state.helper} siteurl={this.props.context.pageContext.web.serverRelativeUrl} dateFormat={this.props.dateFormat} />
</div>
}
{selectedMenu == "3" &&
<div className={css(styles.menuContent)}>
<TemplatesView helper={this.state.helper} siteurl={this.props.context.pageContext.web.serverRelativeUrl} dateFormat={this.props.dateFormat} />
</div>
}
{selectedMenu == "4" &&
<div className={css(styles.menuContent)}>
<SyncJobsView helper={this.state.helper} dateFormat={this.props.dateFormat} />
</div>
}
</>
)}
</>
)}
</>
)}
</>
)}
</div>
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,112 @@
import * as React from 'react';
import styles from '../SpupsProperySync.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { DetailsList, IColumn, DetailsListLayoutMode, ConstrainMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList';
import { IPersonaSharedProps, Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { css } from 'office-ui-fabric-react/lib';
import SPHelper from '../../../../Common/SPHelper';
import MessageContainer from '../MessageContainer';
import { MessageScope } from '../../../../Common/IModel';
const map: any = require('lodash/map');
const union: any = require('lodash/union');
export interface ISyncJobResultsProps {
helper: SPHelper;
data: string;
error: string;
}
export default function SyncJobResults(props: ISyncJobResultsProps) {
const [loading, setLoading] = React.useState<boolean>(true);
const [jobresults, setJobResults] = React.useState<any[]>([]);
const [columns, setColumns] = React.useState<IColumn[]>([]);
const _buildColumns = (colValues: string[]) => {
let cols: IColumn[] = [];
colValues.map(col => {
if (col.toLowerCase() == "userid") {
cols.push({
key: 'UserID', name: 'User ID', fieldName: col, minWidth: 250, maxWidth: 250,
onRender: (item: any, index: number, column: IColumn) => {
const authorPersona: IPersonaSharedProps = {
imageUrl: `/_layouts/15/userphoto.aspx?Size=S&Username=${item[col]}`,
text: item[col],
};
return (
<div><Persona {...authorPersona} size={PersonaSize.size24} /></div>
);
}
} as IColumn);
} else {
cols.push({
key: col, name: col, fieldName: col, minWidth: 100, maxWidth: 250,
onRender: (item: any, index: number, column: IColumn) => {
return (
<>
{(item[col] && item[col] != '') ? (
<>{item[col]} - <span className={css(styles.resultsIconSpan, styles.status, styles.green)}><Icon iconName="Completed" /></span></>
) : (
<>{"Empty"} - <span className={css(styles.resultsIconSpan, styles.status, styles.red)}><Icon iconName="ErrorBadge" /></span></>
)}
</>
);
}
} as IColumn);
}
});
setColumns(cols);
};
const _buildJobResults = () => {
if (props.error && props.error.length > 0) {
} else {
let parsedResults = JSON.parse(props.data);
let colValues = ['UserID'];
colValues = union(colValues, map(parsedResults.value[0].properties, 'name'));
_buildColumns(colValues);
let users = [];
map(parsedResults.value, (userProps) => {
var obj = new Object();
obj['UserID'] = userProps.userid;
map(userProps.properties, (prop) => {
obj[prop.name] = prop.value;
});
users.push(obj);
});
setJobResults(users);
}
setLoading(false);
};
React.useEffect(() => {
_buildJobResults();
}, [props.data]);
return (
<div style={{ maxHeight: '600', maxWidth: '600', overflow: 'auto' }}>
{loading &&
<ProgressIndicator label={strings.PropsLoader} description={strings.JobResultsLoaderDesc} />
}
{!loading && jobresults && jobresults.length > 0 &&
<DetailsList
items={jobresults}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true}
className={styles.uppropertylist} />
}
{props.error && props.error.length > 0 &&
<MessageContainer MessageScope={MessageScope.Failure} Message={`${strings.SyncFailedErrorMessage} ${props.error}`} />
}
</div>
);
}

View File

@ -0,0 +1,193 @@
import * as React from 'react';
import styles from '../SpupsProperySync.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { DetailsList, IColumn, DetailsListLayoutMode, ConstrainMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList';
import { IPersonaSharedProps, Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox';
import { Icon, IIconProps } from 'office-ui-fabric-react/lib/Icon';
import { ActionButton, IconButton } from 'office-ui-fabric-react/lib/Button';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog';
import { css } from 'office-ui-fabric-react/lib';
import SPHelper from '../../../../Common/SPHelper';
import * as moment from 'moment/moment';
import MessageContainer from '../MessageContainer';
import { MessageScope } from '../../../../Common/IModel';
import SyncJobResults from './SyncJobResults';
const orderBy: any = require('lodash/orderBy');
const filter: any = require('lodash/filter');
export interface ISyncJobsProps {
helper: SPHelper;
dateFormat: string;
}
export default function SyncJobsView(props: ISyncJobsProps) {
const actionIcon: IIconProps = { iconName: 'InfoSolid' };
const refreshIcon: IIconProps = { iconName: 'Refresh' };
const [refreshLoading, setRefreshLoading] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<boolean>(true);
const [jobs, setJobs] = React.useState<any[]>([]);
const [filteredjobs, setFilteredJobs] = React.useState<any[]>([]);
const [columns, setColumns] = React.useState<IColumn[]>([]);
const [jobresults, setJobResults] = React.useState<string>('');
const [errorMsg, setErrorMessage] = React.useState<string>('');
const [hideDialog, setHideDialog] = React.useState<boolean>(true);
const [searchKey, setSearchKey] = React.useState<string>('');
const [emptySearch, setEmptySearch] = React.useState<boolean>(false);
const actionClick = (data) => {
setJobResults(data.SyncResults);
setErrorMessage(data.ErrorMessage);
setHideDialog(false);
};
const StatusRender = (childprops) => {
switch (childprops.Status.toLowerCase()) {
case 'submitted':
return (<div className={css(styles.status, styles.blue)}><Icon iconName="Save" /> {childprops.Status}</div>);
case 'in-progress':
return (<div className={css(styles.status, styles.orange)}><Icon iconName="ProgressRingDots" /> {childprops.Status}</div>);
case 'completed':
return (<div className={css(styles.status, styles.green)}><Icon iconName="Completed" /> {childprops.Status}</div>);
case 'error':
case 'completed with error':
return (<div className={css(styles.status, styles.red)}><Icon iconName="ErrorBadge" /> {childprops.Status}</div>);
}
};
const ActionRender = (actionProps) => {
return (
<ActionButton iconProps={actionIcon} allowDisabledFocus onClick={() => { actionClick(actionProps); }} disabled={actionProps.disabled} />
);
};
const _buildColumns = () => {
let cols: IColumn[] = [];
cols.push({ key: 'ID', name: 'ID', fieldName: 'ID', minWidth: 50, maxWidth: 50 } as IColumn);
cols.push({ key: 'Title', name: 'Title', fieldName: 'Title', minWidth: 250, maxWidth: 250 } as IColumn);
cols.push({ key: 'SyncType', name: 'Sync Type', fieldName: 'SyncType', minWidth: 150, maxWidth: 150 } as IColumn);
cols.push({
key: 'Author', name: 'Author', fieldName: 'Author.Title', minWidth: 250, maxWidth: 250,
onRender: (item: any, index: number, column: IColumn) => {
const authorPersona: IPersonaSharedProps = {
imageUrl: `/_layouts/15/userphoto.aspx?Size=S&Username=${item["Author"].EMail}`,
text: item["Author"].Title,
};
return (
<div><Persona {...authorPersona} size={PersonaSize.size24} /></div>
);
}
} as IColumn);
cols.push({
key: 'Created', name: 'Created', fieldName: 'Created', minWidth: 150, maxWidth: 150,
onRender: (item: any, index: number, column: IColumn) => {
return (<div>{moment(item.Created).format(props.dateFormat)}</div>);
}
} as IColumn);
cols.push({
key: 'Status', name: 'Status', fieldName: 'Status', minWidth: 200, maxWidth: 200,
onRender: (item: any, index: number, column: IColumn) => {
return (<StatusRender Status={item.Status} />);
}
} as IColumn);
cols.push({
key: 'Actions', name: 'Actions', fieldName: 'ID', minWidth: 100, maxWidth: 100,
onRender: (item: any, index: number, column: IColumn) => {
let disabled: boolean = ((item.Status.toLowerCase() == "error" && item.ErrorMessage && item.ErrorMessage.length > 0) || item.Status.toLowerCase().indexOf('completed') >= 0) ? false : true;
return (<ActionRender SyncResults={item.SyncedData} ErrorMessage={item.ErrorMessage} disabled={disabled} />);
}
});
setColumns(cols);
};
const _loadJobsList = async () => {
let jobslist = await props.helper.getAllJobs();
jobslist = orderBy(jobslist, ['ID'], ['desc']);
setJobs(jobslist);
};
const _buildJobsList = async () => {
_buildColumns();
await _loadJobsList();
setLoading(false);
};
const _closeDialog = () => {
setHideDialog(true);
};
const _refreshList = async () => {
setRefreshLoading(true);
await _loadJobsList();
setRefreshLoading(false);
};
const _searchJobsList = (srchkey) => {
setEmptySearch(false);
setSearchKey(srchkey);
let filteredList = filter(jobs, (o) => {
return o.Title.toLowerCase().indexOf(srchkey.toLowerCase()) >= 0 || o['Author'].Title.toLowerCase().indexOf(srchkey.toLowerCase()) >= 0 ||
o.Status.toLowerCase().indexOf(srchkey.toLowerCase()) >= 0 || o.SyncType.toLowerCase().indexOf(srchkey.toLowerCase()) >= 0;
});
if (filteredList.length <= 0) setEmptySearch(true);
setFilteredJobs(filteredList);
};
React.useEffect(() => {
_buildJobsList();
}, [props.dateFormat]);
return (
<div className={styles.syncjobsContainer}>
{loading &&
<ProgressIndicator label={strings.PropsLoader} description={strings.JobsListLoaderDesc} />
}
{(!loading && jobs && jobs.length > 0) ? (
<>
<div className={styles.searchcontainer}>
<SearchBox placeholder={strings.JobsListSearchPH} underlined={true} value={searchKey} onChange={_searchJobsList} />
</div>
<div className={styles.refreshContainer}>
<IconButton iconProps={refreshIcon} title="Refresh" ariaLabel="Refresh" onClick={_refreshList} disabled={refreshLoading} />
{refreshLoading &&
<Spinner size={SpinnerSize.small} />
}
</div>
{emptySearch &&
<div style={{ marginTop: '-10px', width: '95%' }}>
<MessageContainer MessageScope={MessageScope.Failure} Message={strings.EmptySearchResults} />
</div>
}
<DetailsList
items={filteredjobs && filteredjobs.length > 0 ? filteredjobs : jobs}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true}
className={styles.uppropertylist} />
</>
) : (
<>
{!loading &&
<MessageContainer MessageScope={MessageScope.Info} Message={strings.EmptyTable} />
}
</>
)}
<Dialog hidden={hideDialog} onDismiss={_closeDialog} minWidth='400' maxWidth='700'
dialogContentProps={{
type: DialogType.close,
title: `${strings.JobResultsDialogTitle}`
}}
modalProps={{
isBlocking: true,
isDarkOverlay: true,
styles: { main: { minWidth: 400, maxHeight: 700 } },
}}>
<SyncJobResults helper={props.helper} data={jobresults} error={errorMsg} />
</Dialog>
</div>
);
}

View File

@ -0,0 +1,57 @@
import * as React from 'react';
import styles from '../SpupsProperySync.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { FileContentType } from '../../../../Common/IModel';
import SPHelper from '../../../../Common/SPHelper';
export interface ITemplatesStructureProps {
helper: SPHelper;
fileurl: string;
}
export default function TemplateStructure(props: ITemplatesStructureProps) {
const [loading, setLoading] = React.useState<boolean>(true);
const [userprops, setUserProps] = React.useState<any[]>([]);
const _loadTemplateStructure = async () => {
let fileextn: string = props.fileurl.split('.').pop();
let filecontent: any = null;
let finaljson: any[] = [];
if (fileextn.toLowerCase() === "csv") {
let csvcontent = await props.helper.getFileContent(props.fileurl, FileContentType.Text);
let re = /\"/gi;
csvcontent.split(',').map((prop: string) => {
finaljson.push(prop.replace(re, ''));
});
} else if (fileextn.toLowerCase() === "json") {
filecontent = await props.helper.getFileContent(props.fileurl, FileContentType.JSON);
Object.keys(filecontent[0]).map((key) => {
finaljson.push(key);
});
}
setLoading(false);
setUserProps(finaljson);
};
React.useEffect(() => {
_loadTemplateStructure();
}, [props.fileurl]);
return (
<div>
{loading &&
<Spinner size={SpinnerSize.small} label={strings.TemplatePropsLoaderDesc} labelPosition={"top"} />
}
{!loading && userprops && userprops.length > 0 &&
<div className={styles.propertyMappingContainer} data-is-focusable={true}>
{userprops.map((userprop: string) => {
return <div className={styles.propertydiv}>{userprop}</div>;
})
}
</div>
}
</div>
);
}

View File

@ -0,0 +1,207 @@
import * as React from 'react';
import styles from '../SpupsProperySync.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { DetailsList, IColumn, DetailsListLayoutMode, ConstrainMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList';
import { IPersonaSharedProps, Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox';
import { ActionButton, IconButton } from 'office-ui-fabric-react/lib/Button';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog';
import { Link } from 'office-ui-fabric-react/lib/Link';
import { Icon, IconType, IIconProps } from 'office-ui-fabric-react/lib/Icon';
import * as moment from 'moment/moment';
import SPHelper from '../../../../Common/SPHelper';
import MessageContainer from '../MessageContainer';
import { MessageScope, FileContentType } from '../../../../Common/IModel';
import TemplateStructure from './TemplatesStructure';
const orderBy: any = require('lodash/orderBy');
const filter: any = require('lodash/filter');
export interface ITemplatesProps {
helper: SPHelper;
siteurl: string;
dateFormat: string;
}
export default function TemplatesView(props: ITemplatesProps) {
const actionIcon: IIconProps = { iconName: 'InfoSolid' };
const refreshIcon: IIconProps = { iconName: 'Refresh' };
const [refreshLoading, setRefreshLoading] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<boolean>(true);
const [downloadLoading, setDownloadLoading] = React.useState<boolean>(false);
const [templates, setTemplates] = React.useState<any[]>([]);
const [filteredtemplates, setFilteredTemplates] = React.useState<any[]>([]);
const [columns, setColumns] = React.useState<IColumn[]>([]);
const [searchKey, setSearchKey] = React.useState<string>('');
const [emptySearch, setEmptySearch] = React.useState<boolean>(false);
const [hideDialog, setHideDialog] = React.useState<boolean>(true);
const [fileurl, setFileUrl] = React.useState<string>('');
const downloadTemplate = async (fileserverurl, filename) => {
setDownloadLoading(true);
const anchor = window.document.createElement('a');
anchor.href = `${props.siteurl}/_layouts/15/download.aspx?SourceUrl=${fileserverurl}`;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setDownloadLoading(false);
};
const actionClick = async (data) => {
if (data.FileUrl) {
setFileUrl(data.FileUrl);
setHideDialog(false);
}
};
const ActionRender = (actionProps) => {
return (
<ActionButton iconProps={actionIcon} allowDisabledFocus onClick={() => { actionClick(actionProps); }} />
);
};
const _buildColumns = () => {
let cols: IColumn[] = [];
cols.push({
key: 'Name', name: 'Name', fieldName: 'Name', minWidth: 300, maxWidth: 300,
onRender: (item: any, index: number, column: IColumn) => {
let fileextn = item.Name.split('.').pop();
return (
<>
<div className={styles.fileiconDiv}>
{fileextn.toLowerCase() === "csv" &&
<Icon iconName="ExcelDocument" ariaLabel={item.Name} iconType={IconType.Default} />
}
{fileextn.toLowerCase() === "json" &&
<Icon iconName="FileCode" ariaLabel={item.Name} iconType={IconType.Default} />
}
</div>
<Link onClick={() => { downloadTemplate(`${item.ServerRelativeUrl}`, `${item.Name}`); }} value={item.Name}>{item.Name}</Link>
{downloadLoading &&
<div className={styles.downloadLoaderDiv}>
<Spinner size={SpinnerSize.small} />
</div>
}
</>
);
}
});
cols.push({
key: 'Author', name: 'Author', fieldName: 'Author', minWidth: 300, maxWidth: 300,
onRender: (item: any, index: number, column: IColumn) => {
const authorPersona: IPersonaSharedProps = {
imageUrl: `/_layouts/15/userphoto.aspx?Size=S&Username=${item["Author"].Email}`,
text: item["Author"].Title,
};
return (
<div><Persona {...authorPersona} size={PersonaSize.size24} /></div>
);
}
} as IColumn);
cols.push({
key: 'TimeCreated', name: 'Created', fieldName: 'TimeCreated', minWidth: 100, maxWidth: 200,
onRender: (item: any, index: number, column: IColumn) => {
return (
<div>{moment(item.TimeCreated).format(props.dateFormat)}</div>
);
}
} as IColumn);
cols.push({
key: 'Actions', name: 'Actions', fieldName: 'ID', minWidth: 100, maxWidth: 100,
onRender: (item: any, index: number, column: IColumn) => {
return (<ActionRender FileUrl={item.ServerRelativeUrl} />);
}
});
setColumns(cols);
};
const _loadTemplatesList = async () => {
let templateList = await props.helper.getAllTemplates();
templateList = orderBy(templateList, ['TimeCreated'], ['desc']);
setTemplates(templateList);
};
const _buildTemplatesList = async () => {
_buildColumns();
await _loadTemplatesList();
setLoading(false);
};
const _refreshList = async () => {
setRefreshLoading(true);
await _loadTemplatesList();
setRefreshLoading(false);
};
const _searchTemplatesList = (srchkey) => {
setEmptySearch(false);
setSearchKey(srchkey);
let filteredList = filter(templates, (o) => {
return o.Name.toLowerCase().indexOf(srchkey.toLowerCase()) >= 0 || o['Author'].Title.toLowerCase().indexOf(srchkey.toLowerCase()) >= 0;
});
if (filteredList.length <= 0) setEmptySearch(true);
setFilteredTemplates(filteredList);
};
const _closeDialog = () => {
setHideDialog(true);
};
React.useEffect(() => {
_buildTemplatesList();
}, [props.dateFormat]);
return (
<div className={styles.templatesContainer}>
{loading &&
<ProgressIndicator label={strings.PropsLoader} description={strings.TemplateListLoaderDesc} />
}
{(!loading && templates && templates.length > 0) ? (
<>
<div className={styles.searchcontainer}>
<SearchBox placeholder={strings.TemplateListSearchPH} underlined={true} value={searchKey} onChange={_searchTemplatesList} />
</div>
<div className={styles.refreshContainer}>
<IconButton iconProps={refreshIcon} title="Refresh" ariaLabel="Refresh" onClick={_refreshList} disabled={refreshLoading} />
{refreshLoading &&
<Spinner size={SpinnerSize.small} />
}
</div>
{emptySearch &&
<div style={{ marginTop: '-10px', width: '95%' }}>
<MessageContainer MessageScope={MessageScope.Failure} Message={strings.EmptySearchResults} />
</div>
}
<div className={styles.templateList}>
<DetailsList
items={filteredtemplates && filteredtemplates.length > 0 ? filteredtemplates : templates}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true}
className={styles.uppropertylist} />
</div>
</>
) : (
<>
{!loading &&
<MessageContainer MessageScope={MessageScope.Info} Message={strings.EmptyTable} />
}
</>
)
}
<Dialog hidden={hideDialog} onDismiss={_closeDialog} maxWidth='700' minWidth='500px'
dialogContentProps={{
type: DialogType.close,
title: `${strings.TemplateStructureDialogTitle}`
}}
modalProps={{
isBlocking: true,
isDarkOverlay: true,
styles: { main: { minWidth: 500, maxHeight: 700 } },
}}>
<TemplateStructure helper={props.helper} fileurl={fileurl} />
</Dialog>
</div >
);
}

View File

@ -0,0 +1,137 @@
import * as React from 'react';
import styles from './SpupsProperySync.module.scss';
import * as strings from 'SpupsProperySyncWebPartStrings';
import { DetailsList, IColumn, DetailsListLayoutMode, ConstrainMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList';
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import * as csv from 'csvtojson';
import MessageContainer from './MessageContainer';
import { MessageScope } from '../../../Common/IModel';
export interface IUPPropertyDataProps {
items: any;
isCSV: boolean;
showProgress: boolean;
clearData: boolean;
UpdateSPForBulkUsers: (data: any[]) => void;
}
export interface IUPPropertyDataState {
items: any;
columns: IColumn[];
dynamicColumns: string[];
searchText: string;
emptyValues: boolean;
}
export default class UPPropertyData extends React.Component<IUPPropertyDataProps, IUPPropertyDataState> {
constructor(props: IUPPropertyDataProps) {
super(props);
this.state = {
items: [],
columns: [],
searchText: '',
dynamicColumns: [],
emptyValues: false
};
}
public componentDidMount = () => {
this._buildUploadDataList();
}
public componentDidUpdate = (prevProps: IUPPropertyDataProps) => {
if (prevProps.items !== this.props.items || prevProps.isCSV !== this.props.isCSV) {
this._buildUploadDataList();
}
if (prevProps.clearData !== this.props.clearData) {
if (this.props.clearData) this.setState({ items: [] });
}
}
private _buildColumns = (columns: string[]): IColumn[] => {
this.setState({ emptyValues: false });
let cols: IColumn[] = [];
if (columns && columns.length > 0) {
columns.map((col: string) => {
if (col.toLocaleLowerCase() == "userid") {
cols.push({ key: col, name: col, fieldName: col, minWidth: 300, maxWidth: 300 } as IColumn);
} else {
cols.push({
key: col, name: col, fieldName: col, minWidth: 150,
onRender: (item: any, index: number, column: IColumn) => {
if (item[col]) {
return (<div>{item[col]}</div>);
} else {
this.setState({ emptyValues: true });
return (<div className={styles.emptyData}>{strings.EmptyDataText}</div>);
}
}
} as IColumn);
}
});
}
return cols;
}
private _buildUploadDataList = async () => {
const { items, isCSV } = this.props;
if (items) {
if (isCSV) {
let finalOut: any = await csv().fromString(items);
this._getJSONData(finalOut);
}
else this._getJSONData(items);
}
}
private _getJSONData = (inputjson?: any) => {
let parsedJson = (inputjson) ? inputjson : JSON.parse(inputjson);
let _dynamicColumns: string[] = [];
Object.keys(parsedJson[0]).map((key) => {
_dynamicColumns.push(key);
});
this.setState({
columns: this._buildColumns(_dynamicColumns),
items: parsedJson
});
}
private _updatePropsForBulkUsers = () => {
this.props.UpdateSPForBulkUsers(this.state.items);
this.setState({ emptyValues: false });
}
public render(): JSX.Element {
const { items, columns, emptyValues } = this.state;
return (
<div className={styles.uppropertydata}>
{emptyValues && !this.props.clearData &&
<MessageContainer MessageScope={MessageScope.Info} Message={strings.EmptyDataWarningMsg} />
}
{(items && items.length > 0) ? (
<>
<DetailsList
items={items}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true}
className={styles.uppropertylist} />
<div style={{ padding: "10px" }}>
<PrimaryButton text={strings.BtnUpdateUserProps} onClick={this._updatePropsForBulkUsers} style={{ marginRight: '5px' }} disabled={this.props.showProgress} />
{this.props.showProgress && <Spinner className={styles.generateTemplateLoader} label={strings.PropsUpdateLoader} ariaLive="assertive" labelPosition="right" />}
</div>
</>
) : (
<></>
)
}
</div>
);
}
}

View File

@ -0,0 +1,76 @@
define([], function() {
return {
PropertyPaneDescription: "",
BasicGroupName: "Configurations",
ListCreationText: "Verifying the required list and loading the properties...",
PropTemplateLibLabel: "Select a library to store the templates",
PropAzFuncLabel: "Azure Function URL",
PropAzFuncDesc: "Azure powershell function to update the user profile properties in SharePoint UPS",
PropUseCertLabel: "Use Certificate for Azure Function authentication",
PropUseCertCallout: "Turn on this option to use certificate for authenticating SharePoint communication via Azure Function",
PropDateFormatLabel: "Date format",
PropInfoDateFormat: "The date format use <strong>momentjs</strong> date format. Please <a href='https://momentjs.com/docs/#/displaying/format/' target='_blank'>click here</a> to get more info on how to define the format. By default the format is '<strong>DD, MMM YYYY hh:mm A</strong>'",
PropInfoUseCert: "Please <a href='https://www.youtube.com/watch?v=plS_1BsQAto&list=PL-KKED6SsFo8TxDgQmvMO308p51AO1zln&index=2&t=0s' target='_blank'>click here</a> to see how to create Azure powershell function with different authentication mechanism.",
PropInfoTemplateLib: "Document library to maintain the templates and batch files uploaded. </br>'<strong>SyncJobTemplate</strong>' folder will be created to maintain the templates.</br>'<strong>UPSDataToProcess</strong>' folder will be created to maintain the files uploaded for bulk sync.",
PropInfoNormalUser: "Sorry, the configuration is enabled only for the site administrators, please contact your site administrator!",
PropAllowedUserInfo: "Only SharePoint groups are allowed in this setting. Only memebers of the SharePoint groups defined above will have access to this web part.",
PlaceholderIconText: "Configure the settings",
PlaceholderDescription: "Use the configuration settings to map the document library required to store the property mapping templates.",
PlaceholderButtonLabel: "Configure",
DefaultAppTitle: "SharePoint Profile Property Sync",
JobResultsDialogTitle: "Users list with properties updated!",
JobsListSearchPH: "Search by Title, SyncType, Author, Status...",
TemplateListSearchPH: "Search by Name, Author...",
TemplateStructureDialogTitle: "Properties defined in the template!",
BulkSyncDataDialogTitle: "Data defined in the file!",
GenerateTemplateLoader: "Wait, generating the template...",
UploadDataToSyncLoader: "Wait, uploading data for syncing",
PropsLoader: "Please wait...",
PropsUpdateLoader: "Please wait, initializing the job to update the properties",
JobsListLoaderDesc: "Loading the jobs list...",
JobResultsLoaderDesc: "Loading the results...",
TemplateListLoaderDesc: "Loading the templates...",
TemplatePropsLoaderDesc: "Loading properties, please wait...",
BulkSyncListLoaderDesc: "Loading the bulk sync files...",
BulkSyncFileDataLoaderDesc: "Loading data, please wait...",
AccessCheckDesc: "Checking for access...",
SitePrivilegeCheckLabel: "Checking site admin privilege...",
BtnGenerateJSON: "Generate JSON",
BtnGenerateCSV: "Generate CSV",
BtnSaveForManual: "Save for Manual Update",
BtnPropertyMapping: "Property Mapping",
BtnUploadDataForSync: "Upload Data to Sync",
BtnUpdateUserProps: "Update User Properties",
BtnManualProps: "Initialize Manual Properties",
BtnAzureProps: "Get Azure Properties",
PnlHeaderText: "Property Mappings",
TblColHeadAzProperty: "Azure Property",
TblColHeadSPProperty: "SharePoint Property",
TblColHeadEnableDisable: "Enabled/Disabled",
PPLPickerTitleText: "Select users to update their properties",
EmptyPropertyMappings: "No active property mappings found. Please navigate to 'Sync Properties Mapping' list or contact your administrator to activate the properties.",
TemplateDownloaded: "Please use the downloaded file to update the User properties!",
EmptyDataText: "Empty!",
EmptyDataWarningMsg: "Columns with empty values are not considered for update!",
EmptyTable: "Sorry, no data to be displayed!",
EmptyFile: "Oops, the file is empty",
EmptySearchResults: "Sorry, no data found. Displaying all the data",
UserListChanges: "Changes in user list, please remove the user from the table manually or reinitialize or get the Azure properties again!",
UserListEmpty: "Since all the users have been removed, the table has been cleared!",
JobIntializedSuccess: "Property sync job has been initialized. Track the status of the job on the 'Sync Jobs' tab!",
AdminConfigHelp: "Please contact your site administrator to configure the webpart.",
AccessDenied: "Access denied. Please contact your administrator.",
SyncFailedErrorMessage: "Oops, there is an error while updating the properties. Error Message:",
TabMenu1: "Manual or Azure Property Sync",
TabMenu2: "Bulk Sync",
TabMenu3: "Bulk Files Uploaded",
TabMenu4: "Templates Generated",
TabMenu5: "Sync Status"
}
});

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