Synchronize photos from AAD and FileShare to SharePoint UPS.

This commit is contained in:
Sudharsan K 2020-09-13 21:59:44 +08:00
parent fc3919f9b3
commit 1a9935799c
38 changed files with 20618 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-photo-sync/.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": true,
"environment": "spo",
"version": "1.11.0",
"libraryName": "react-photo-sync",
"libraryId": "1feb523c-4edf-4b85-a8d3-1458ab23fae9",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,87 @@
# SPUPS Photo Sync
## Summary
> This web part will help the administrators to synchronize the **User Profile Photos** from **Azure AD** or from the **local filesystem** to **SharePoint User Profile Store**.
## Features
* **User selection** will help you to update only specific user based on the selection. It will also allow the users to fetch the photos from **Azure AD** before starting the synchronization.
* **Bulk Sync** will allow the admin to upload the photos from their fileshares. The filename should be in the format '**UserID.jpg**'
* **Access control** based on **SharePoint Group**, not all the users can access the applictaion.
* Separate section to check the **status** of the photo update.
* **Azure Function** to handle the photo 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 thumbnails**: A document library to store the thumbnail photos.
2. **Delete thumbnail stored**: This flag will decide whether you want to keep the thumbnails generated or to clean it after the sync completed.
3. **Azure Function URL**: Azure function URL to run the photo update.
4. **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.
5. **Date format**: Date format to be used across the entire application. Used _**momentJS**_.
6. **SharePoint Groups**: Only the users from the configured SharePoint Groups and Site Administrator shall be allowed access.
7. **Use page full width**: This is used when the web part is added to a site page where it has to use full width.
## Preview
### User Selection sync
![SPUPS-Photo-Sync_1](./assets/SPUPS_Photo_Sync_1.gif)
### Bulk sync
![SPUPS-Photo-Sync_2](./assets/SPUPS_Photo_Sync_2.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.11.0**
## Solution
Solution|Author(s)
--------|---------
SPUPS Photo Sync | Sudharsan K.([@sudharsank](https://twitter.com/sudharsank), [SPKnowledge](https://spknowledge.com/))
## Version history
Version|Date|Comments
-------|----|--------
1.0.0.0|Sep 13 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 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)

View File

@ -0,0 +1,103 @@
# Function to connect to the SharePoint Online using cert or credentials
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
}
}
function uploadToUserProfilePictures ($mysiteurl, $filename, $streamval, $usecert) {
connectSPOnline -targeturl $mysiteurl -usecert $usecert
Add-PnPFile -Stream $streamval -FileName $filename -Folder $userphotofolder
}
function updateUserProperty($adminurl, $mysiteurl, $upn, $pictureName, $usecert) {
connectSPOnline -targeturl $adminurl -usecert $usecert
Set-PnPUserProfileProperty -Account $upn -PropertyName "PictureUrl" -Value "$mysiteurl/$userphotofolder/$pictureName"
Set-PnPUserProfileProperty -Account $upn -PropertyName "SPS-PicturePlaceholderState" -Value 0
}
function getFileAndUpload ($adminurl, $mysiteurl, $targetsiteurl, $userid, $picfolder, $picname, $usecert) {
try {
connectSPOnline -targeturl $targetsiteurl -usecert $usecert
$lfilename = $picname+"_LThumb.jpg"
$lpicurl = $picfolder+$lfilename
$lfs = (Get-PnPFile -ServerRelativeUrl $lpicurl).OpenBinaryStream()
Invoke-PnPQuery
uploadToUserProfilePictures -mysiteurl $mysiteurl -filename $lfilename -streamval $lfs.Value
connectSPOnline -targeturl $targetsiteurl -usecert $usecert
$mfilename = $picname+"_MThumb.jpg"
$mpicurl = $picfolder+$mfilename
$mfs = (Get-PnPFile -ServerRelativeUrl $mpicurl).OpenBinaryStream()
Invoke-PnPQuery
uploadToUserProfilePictures -mysiteurl $mysiteurl -filename $mfilename -streamval $mfs.Value
updateUserProperty -adminurl $adminurl -mysiteurl $mysiteurl -upn $userid -pictureName $mfilename
connectSPOnline -targeturl $targetsiteurl -usecert $usecert
$sfilename = $picname+"_SThumb.jpg"
$spicurl = $picfolder+$sfilename
$sfs = (Get-PnPFile -ServerRelativeUrl $spicurl).OpenBinaryStream()
Invoke-PnPQuery
uploadToUserProfilePictures -mysiteurl $mysiteurl -filename $sfilename -streamval $sfs.Value
return $true
} catch {
return $false
}
}
# Read the request as a JSON object
$jsoninput = Get-Content $req -Raw | ConvertFrom-Json
# Configure local variable
$adminurl = $jsoninput.adminurl
$mysiteurl = $jsoninput.mysiteurl
$targetsiteurl = $jsoninput.targetsiteurl
$picfolder = $jsoninput.picfolder
$usecert = $jsoninput.usecert
$itemid = $jsoninput.itemid
$clearPhotos = $jsoninput.clearPhotos
$targetList = "UPS Photo Sync Jobs"
$userphotofolder = "User Photos/Profile Pictures"
# Update the status of the item to 'In-Progress'
connectSPOnline -targeturl $targetsiteurl -usecert $usecert
Set-PnPListItem -List $targetList -Identity $itemid -Values @{"Status" = "In-Progress" } -SystemUpdate
foreach ($val in $jsoninput.value) {
if ($null -ne $val.userid -and $null -ne $val.picturename) {
$status = getFileAndUpload -adminurl $adminurl -mysiteurl $mysiteurl -targetsiteurl $targetsiteurl -userid $val.userid -picfolder $picfolder -picname $val.picturename -usecert $usecert
if ($null -eq $val.Status) {
if($true -eq $status) {
$val | Add-Member -Name "Status" -Value "Updated" -MemberType NoteProperty
} else {
$val | Add-Member -Name "Status" -Value "Not Updated" -MemberType NoteProperty
}
} else {
if($true -eq $status) {
$val.Status = "Updated"
} else {
$val.Status = "Not Updated"
}
}
# If the clear photos flag is set delete all the temporary thumbnails
connectSPOnline -targeturl $targetsiteurl -usecert $usecert
if($true -eq $clearPhotos) {
$filename = "{0}{1}{2}" -f $picfolder,$val.picturename,"_LThumb.jpg"
Remove-PnPFile -ServerRelativeUrl $filename -Force
$filename = "{0}{1}{2}" -f $picfolder,$val.picturename,"_MThumb.jpg"
Remove-PnPFile -ServerRelativeUrl $filename -Force
$filename = "{0}{1}{2}" -f $picfolder,$val.picturename,"_SThumb.jpg"
Remove-PnPFile -ServerRelativeUrl $filename -Force
}
}
}
# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"photo-sync-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/photoSync/PhotoSyncWebPart.js",
"manifest": "./src/webparts/photoSync/PhotoSyncWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"PhotoSyncWebPartStrings": "lib/webparts/photoSync/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-photo-sync",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,31 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "SPUPS Photo Sync",
"title": "SPUPS Photo Sync",
"id": "1feb523c-4edf-4b85-a8d3-1458ab23fae9",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false,
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "User.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.Read.All"
}
],
"developer": {
"name": "Sudharsan K.",
"mpnId": "",
"privacyUrl": "",
"termsOfUseUrl": "",
"websiteUrl": "https://spknowledge.com/"
}
},
"paths": {
"zippedPackage": "solution/react-photo-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 -->"
}

7
samples/react-photo-sync/gulpfile.js vendored Normal file
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'));

17935
samples/react-photo-sync/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
{
"name": "react-photo-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"
},
"dependencies": {
"@microsoft/sp-core-library": "1.11.0",
"@microsoft/sp-lodash-subset": "1.11.0",
"@microsoft/sp-office-ui-fabric-core": "1.11.0",
"@microsoft/sp-property-pane": "1.11.0",
"@microsoft/sp-webpart-base": "1.11.0",
"@pnp/common": "^2.0.6",
"@pnp/graph": "^2.0.6",
"@pnp/odata": "^2.0.6",
"@pnp/sp": "^2.0.6",
"@pnp/spfx-controls-react": "1.19.0",
"@pnp/spfx-property-controls": "1.19.0",
"image-resize": "^1.1.5",
"lodash": "^4.17.15",
"moment": "^2.27.0",
"office-ui-fabric-react": "6.214.0",
"react": "16.8.5",
"react-dom": "16.8.5",
"react-dropzone": "^11.0.3"
},
"resolutions": {
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/rush-stack-compiler-3.3": "0.3.5",
"@microsoft/sp-build-web": "1.11.0",
"@microsoft/sp-module-interfaces": "1.11.0",
"@microsoft/sp-tslint-rules": "1.11.0",
"@microsoft/sp-webpart-workbench": "1.11.0",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2",
"gulp": "~3.9.1",
"jquery": "^3.5.1"
}
}

View File

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

View File

@ -0,0 +1,21 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "08a4b451-7012-4f86-9492-5c3196691cc8",
"alias": "PhotoSyncWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart", "SharePointFullPage", "TeamsPersonalApp"],
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Other" },
"title": { "default": "SPUPS Photo Sync" },
"description": { "default": "Sync photos from AAD to SharePoint UPS." },
"officeFabricIconFontName": "EditPhoto",
"properties": {
"useFullWidth": false
}
}]
}

View File

@ -0,0 +1,240 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import * as strings from 'PhotoSyncWebPartStrings';
import { Version } from '@microsoft/sp-core-library';
import {
IPropertyPaneConfiguration,
PropertyPaneTextField,
IPropertyPanePage
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { CalloutTriggers } from '@pnp/spfx-property-controls/lib/PropertyFieldHeader';
import { PropertyFieldListPicker, PropertyFieldListPickerOrderBy } from '@pnp/spfx-property-controls/lib/PropertyFieldListPicker';
import { PropertyFieldToggleWithCallout } from '@pnp/spfx-property-controls/lib/PropertyFieldToggleWithCallout';
import { PropertyPaneWebPartInformation } from '@pnp/spfx-property-controls/lib/PropertyPaneWebPartInformation';
import { PropertyFieldPeoplePicker, PrincipalType, IPropertyFieldGroupOrPerson } from '@pnp/spfx-property-controls/lib/PropertyFieldPeoplePicker';
import { MSGraphClient } from '@microsoft/sp-http';
import { sp } from '@pnp/sp';
import { ISiteUserInfo } from '@pnp/sp/site-users/types';
import { IPhotoSyncProps } from './components/PhotoSync';
import PhotoSync from './components/PhotoSync';
import Helper, { IHelper } from './common/helper';
export interface IPhotoSyncWebPartProps {
useFullWidth: boolean;
appTitle: string;
allowedUsers: IPropertyFieldGroupOrPerson[];
enableBulkUpdate: boolean;
tempLib: string;
UseCert: boolean;
dateFormat: string;
deleteThumbnails: boolean;
AzFuncUrl: string;
}
export default class PhotoSyncWebPart extends BaseClientSideWebPart<IPhotoSyncWebPartProps> {
private wpPropertyPages: IPropertyPanePage[] = [];
private helper: IHelper = null;
private client: MSGraphClient = null;
protected async onInit() {
await super.onInit();
sp.setup(this.context);
this.client = await this.context.msGraphClientFactory.getClient();
this.helper = new Helper(this.context.pageContext.web.serverRelativeUrl, '', this.client);
}
public async render(): Promise<void> {
const element: React.ReactElement<IPhotoSyncProps> = React.createElement(
PhotoSync,
{
context: this.context,
httpClient: this.context.httpClient,
siteUrl: this.context.pageContext.legacyPageContext.webAbsoluteUrl,
domainName: this.context.pageContext.legacyPageContext.webDomain,
displayMode: this.displayMode,
helper: this.helper,
useFullWidth: this.properties.useFullWidth,
appTitle: this.properties.appTitle,
updateProperty: (value: string) => {
this.properties.appTitle = value;
},
openPropertyPane: this.openPropertyPane,
allowedUsers: this.properties.allowedUsers,
enableBulkUpdate: this.properties.enableBulkUpdate,
tempLib: this.properties.tempLib,
deleteThumbnails: this.properties.deleteThumbnails,
UseCert: this.properties.UseCert,
dateFormat: this.properties.dateFormat,
AzFuncUrl: this.properties.AzFuncUrl
}
);
ReactDom.render(element, this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected get disableReactivePropertyChanges() {
return true;
}
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('tempLib', {
key: 'tempLibFieldId',
label: strings.PropTempLibLabel,
selectedList: this.properties.tempLib,
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.PropInfoTempLib}`,
key: 'tempLibInfoId'
}),
PropertyFieldToggleWithCallout('deleteThumbnails', {
calloutTrigger: CalloutTriggers.Hover,
key: 'deleteThumbnailsFieldId',
label: strings.PropDelThumbnail,
calloutContent: React.createElement('div', {}, strings.PropDelThumbnailCallout),
onText: 'ON',
offText: 'OFF',
checked: this.properties.deleteThumbnails
}),
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('enableBulkUpdate', {
key: 'enableBulkUpdateFieldId',
label: strings.PropEnableBUCallout,
onText: 'ON',
offText: 'OFF',
checked: this.properties.enableBulkUpdate
}),
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 currentUserInfo: ISiteUserInfo = await this.helper.getCurrentUserDefaultInfo();
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,17 @@
import * as React from 'react';
import { IHelper } from './helper';
import { DisplayMode } from '@microsoft/sp-core-library';
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface AppContextProps {
context: WebPartContext;
siteurl: string;
domainName: string;
helper: IHelper;
displayMode: DisplayMode;
openPropertyPane: () => void;
tempLib: string;
deleteThumbnails: boolean;
}
export const AppContext = React.createContext<AppContextProps>(undefined);

View File

@ -0,0 +1,26 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.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,50 @@
import * as React from 'react';
import { useEffect, useState, useContext } from 'react';
import * as strings from 'PhotoSyncWebPartStrings';
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { AppContext, AppContextProps } from './AppContext';
import { DisplayMode } from '@microsoft/sp-core-library';
import MessageContainer from './MessageContainer';
import { MessageScope } from './IModel';
import { ISiteUserInfo } from '@pnp/sp/site-users/types';
const ConfigPlaceholder: React.FunctionComponent<{}> = (props) => {
const appContext: AppContextProps = useContext(AppContext);
const [isSiteAdmin, setSiteAdmin] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const _checkForSiteAdmin = async () => {
let currentUserInfo: ISiteUserInfo = await appContext.helper.getCurrentUserDefaultInfo();
setSiteAdmin(currentUserInfo.IsSiteAdmin);
setLoading(false);
};
useEffect(() => {
_checkForSiteAdmin();
}, []);
return (
<>
{isSiteAdmin ? (
<Placeholder iconName='DataManagementSettings'
iconText={strings.PlaceholderIconText}
description={strings.PlaceholderDescription}
buttonLabel={strings.PlaceholderButtonLabel}
hideButton={appContext.displayMode === DisplayMode.Read}
onConfigure={appContext.openPropertyPane} />
) : (
<>
{loading &&
<ProgressIndicator label={strings.SitePrivilegeCheckLabel} description={strings.PropsLoader} />
}
{!loading &&
<MessageContainer MessageScope={MessageScope.SevereWarning} Message={strings.AdminConfigHelp} />
}
</>
)}
</>
);
};
export default ConfigPlaceholder;

View File

@ -0,0 +1,85 @@
import * as React from 'react';
import styles from '../components/PhotoSync.module.scss';
import { css } from 'office-ui-fabric-react/lib/Utilities';
import { IPersonaSharedProps, Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona';
export interface IPersonaRenderProps {
Title: string;
UserID: string;
}
export interface IValueRenderProps {
Value: string;
}
export const PersonaRender = (props: IPersonaRenderProps) => {
const authorPersona: IPersonaSharedProps = {
imageUrl: `/_layouts/15/userphoto.aspx?Size=S&Username=${props.UserID}`,
text: props.Title,
className: styles.divPersona
};
return (
<div className={styles.fieldCustomizer}><Persona {...authorPersona} size={PersonaSize.size24} /></div>
);
};
export const SyncTypeRender = (props: IValueRenderProps) => {
switch (props.Value.toLowerCase()) {
case 'manual':
return (
<div className={styles.fieldCustomizer}>
<div className={css(styles.fieldContent, styles.purplebgColor)}>
<span className={css(styles.spnContent, styles.purpleBox)}>{props.Value}</span>
</div>
</div>
);
case 'bulk':
return (
<div className={styles.fieldCustomizer}>
<div className={css(styles.fieldContent, styles.yellowbgColor)}>
<span className={css(styles.spnContent, styles.yellowBox)}>{props.Value}</span>
</div>
</div>
);
}
};
export const StatusRender = (props: IValueRenderProps) => {
switch (props.Value.toLowerCase()) {
case 'submitted':
case 'updated':
return (
<div className={styles.fieldCustomizer}>
<div className={css(styles.fieldContent, styles.bluebgColor)}>
<span className={css(styles.spnContent, styles.blueBox)}>{props.Value}</span>
</div>
</div>
);
case 'in-progress':
return (
<div className={styles.fieldCustomizer}>
<div className={css(styles.fieldContent, styles.orangebgColor)}>
<span className={css(styles.spnContent, styles.orangeBox)}>{props.Value}</span>
</div>
</div>
);
case 'completed':
return (
<div className={styles.fieldCustomizer}>
<div className={css(styles.fieldContent, styles.greenbgColor)}>
<span className={css(styles.spnContent, styles.greenBox)}>{props.Value}</span>
</div>
</div>
);
case 'error':
case 'completed with error':
case 'not updated':
return (
<div className={styles.fieldCustomizer}>
<div className={css(styles.fieldContent, styles.redbgColor)}>
<span className={css(styles.spnContent, styles.redBox)}>{props.Value}</span>
</div>
</div>
);
}
};

View File

@ -0,0 +1,38 @@
export enum MessageScope {
Success,
Failure,
Warning,
Info,
Blocked,
SevereWarning
}
export interface IUserInfo {
ID: number;
Email: string;
LoginName: string;
DisplayName: string;
Picture: string;
IsSiteAdmin: boolean;
Groups: string[];
}
export interface IUserPickerInfo {
Title: string;
LoginName: string;
PhotoUrl: string;
AADPhotoUrl?: string;
}
export interface IAzFuncValues {
userid: string;
picturename: string;
}
export enum SyncType {
Manual = "Manual",
Bulk = "Bulk",
}
export enum JobStatus {
Submitted = "Submitted",
InProgress = "In-Progress",
Completed = "Completed",
CompletedWithError = "Completed With Error",
Error = "Error"
}

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import styles from './CommonStyle.module.scss';
import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import { Text } from 'office-ui-fabric-react/lib/Text';
import { MessageScope } from './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,496 @@
import { HttpClient, IHttpClientOptions, HttpClientResponse } from '@microsoft/sp-http';
import { MSGraphClient } from '@microsoft/sp-http';
import "@pnp/graph/users";
import "@pnp/graph/photos";
import "@pnp/graph/groups";
import { sp } from '@pnp/sp';
import "@pnp/sp/profiles";
import "@pnp/sp/webs";
import "@pnp/sp/lists";
import "@pnp/sp/items";
import "@pnp/sp/fields/list";
import "@pnp/sp/views/list";
import "@pnp/sp/site-users";
import "@pnp/sp/files";
import "@pnp/sp/folders";
import { Web, IWeb } from "@pnp/sp/webs";
import { ISiteUserInfo, ISiteUser } from "@pnp/sp/site-users/types";
import { PnPClientStorage, dateAdd } from '@pnp/common';
import { IUserInfo, IUserPickerInfo, SyncType, JobStatus, IAzFuncValues } from './IModel';
import * as moment from 'moment';
import ImageResize from 'image-resize';
import "@pnp/sp/search";
import { SearchQueryBuilder, SearchResults, ISearchQuery } from "@pnp/sp/search";
import { ChoiceFieldFormatType } from '@pnp/sp/fields/types';
const storage = new PnPClientStorage();
const imgResize_48 = new ImageResize({ format: 'png', width: 48, height: 48, output: 'base64' });
const imgResize_96 = new ImageResize({ format: 'png', width: 96, height: 96, output: 'base64' });
const imgResize_240 = new ImageResize({ format: 'png', width: 240, height: 240, output: 'base64' });
const map: any = require('lodash/map');
const intersection: any = require('lodash/intersection');
const orderBy: any = require('lodash/orderBy');
const chunk: any = require('lodash/chunk');
const flattenDeep: any = require('lodash/flattenDeep');
const batchItemLimit: number = 18;
const userBatchLimit: number = 6;
const userDefStorageKey: string = 'userDefaultInfo';
const userCusStorageKey: string = 'userCustomInfo';
export interface IHelper {
getLibraryDetails: (listid: string) => Promise<any>;
dataURItoBlob: (dataURI: any) => Blob;
getCurrentUserDefaultInfo: () => Promise<ISiteUserInfo>;
getCurrentUserCustomInfo: () => Promise<IUserInfo>;
checkCurrentUserGroup: (allowedGroups: string[], userGroups: string[]) => boolean;
getUsersInfo: (UserIds: string[]) => Promise<any[]>;
getUserPhotoFromAADForDisplay: (users: IUserPickerInfo[]) => Promise<any[]>;
getAndStoreUserThumbnailPhotos: (users: IUserPickerInfo[], tempLibId: string) => Promise<IAzFuncValues[]>;
generateAndStorePhotoThumbnails: (fileInfo: any[], tempLibId: string) => Promise<IAzFuncValues[]>;
createSyncItem: (syncType: SyncType) => Promise<number>;
updateSyncItem: (itemid: number, inputJson: string) => void;
getAllJobs: () => Promise<any[]>;
runAzFunction: (httpClient: HttpClient, inputData: any, azFuncUrl: string, itemid: number) => void;
checkAndCreateLists: () => Promise<boolean>;
}
export default class Helper implements IHelper {
private _web: IWeb = null;
private _graphClient: MSGraphClient = null;
private _graphUrl: string = "https://graph.microsoft.com/v1.0";
private web_ServerRelativeURL: string = '';
private TPhotoFolderName: string = 'UserPhotos';
private Lst_SyncJobs = 'UPS Photo Sync Jobs';
constructor(webRelativeUrl: string, weburl?: string, graphClient?: MSGraphClient) {
this._graphClient = graphClient ? graphClient : null;
this._web = weburl ? Web(weburl) : sp.web;
this.web_ServerRelativeURL = webRelativeUrl;
}
/**
* Get temp library details
* @param listid Temporary library
*/
public getLibraryDetails = async (listid: string): Promise<string> => {
let retFolderPath: string = '';
let listDetails = await this._web.lists.getById(listid).get();
retFolderPath = listDetails.DocumentTemplateUrl.replace('/Forms/template.dotx', '') + '/' + this.TPhotoFolderName;
return retFolderPath;
}
/**
* 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);
}
}
/**
* Convert base64 image to blob.
*/
public dataURItoBlob = (dataURI): Blob => {
// convert base64/URLEncoded data component to raw binary data held in a string
var byteString;
if (dataURI.split(',')[0].indexOf('base64') >= 0)
byteString = atob(dataURI.split(',')[1]);
else
byteString = unescape(dataURI.split(',')[1]);
// separate out the mime component
var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
// write the bytes of the string to a typed array
var ia = new Uint8Array(byteString.length);
for (var i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ia], { type: mimeString });
}
/**
* Get current logged in user default info.
*/
public getCurrentUserDefaultInfo = async (): Promise<ISiteUserInfo> => {
//return await this._web.currentUser.get();
let currentUserInfo: ISiteUserInfo = storage.local.get(userDefStorageKey);
if (!currentUserInfo) {
currentUserInfo = await this._web.currentUser.get();
storage.local.put(userDefStorageKey, currentUserInfo, dateAdd(new Date(), 'hour', 1));
}
return currentUserInfo;
}
/**
* Get current logged in user custom information.
*/
public getCurrentUserCustomInfo = 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;
}
/**
* Get user profile photos from Azure AD
*/
public getUserPhotoFromAADForDisplay = async (users: IUserPickerInfo[]): Promise<any[]> => {
return new Promise(async (res, rej) => {
if (users && users.length > 0) {
let requests: any[] = [];
let finalResponse: any[] = [];
if (users.length > batchItemLimit) {
let chunkUserArr: any[] = chunk(users, batchItemLimit);
Promise.all(chunkUserArr.map(async chnkdata => {
requests = [];
chnkdata.map((user: IUserPickerInfo) => {
let upn: string = user.LoginName.split('|')[2];
requests.push({
id: `${user.LoginName}`,
method: 'GET',
responseType: 'blob',
headers: { "Content-Type": "image/jpeg" },
url: `/users/${upn}/photos/$value`
});
});
let photoReq: any = { requests: requests };
let graphRes: any = await this._graphClient.api('$batch').post(photoReq);
finalResponse.push(graphRes);
})).then(() => {
res(finalResponse);
});
} else {
users.map((user: IUserPickerInfo) => {
let upn: string = user.LoginName.split('|')[2];
requests.push({
id: `${user.LoginName}`,
method: 'GET',
responseType: 'blob',
headers: { "Content-Type": "image/jpeg" },
url: `/users/${upn}/photo/$value`
});
});
let photoReq: any = { requests: requests };
finalResponse.push(await this._graphClient.api('$batch').post(photoReq));
res(finalResponse);
}
}
});
}
/**
* Get user info based on UserID
*/
public getUsersInfo = async (userids: string[]): Promise<any[]> => {
return new Promise(async (res, rej) => {
let finalResponse: any[] = [];
let batch = sp.createBatch();
if (userids.length > batchItemLimit) {
let chunkUserArr: any[] = chunk(userids, batchItemLimit);
Promise.all(chunkUserArr.map(async chnkdata => {
batch = sp.createBatch();
finalResponse.push(await this.executeBatch(chnkdata, batch));
})).then(() => {
res(flattenDeep(finalResponse));
});
} else {
batch = sp.createBatch();
finalResponse.push(await this.executeBatch(userids, batch));
res(flattenDeep(finalResponse));
}
});
}
private executeBatch = (chnkdata, batch): Promise<any[]> => {
return new Promise((res, rej) => {
let finalResponse: any[] = [];
batch = sp.createBatch();
chnkdata.map((userid: string) => {
sp.web.siteUsers.getByLoginName(`i:0#.f|membership|${userid}`).inBatch(batch).get().then((userinfo) => {
if (userinfo && userinfo.Title) {
finalResponse.push({
'loginname': userid,
'title': userinfo.Title,
'status': 'Valid'
});
}
}).catch((e) => {
finalResponse.push({
'loginname': userid,
'title': 'User not found!',
'status': 'Invalid'
});
});
});
batch.execute().then(() => {
res(finalResponse);
}).catch(() => {
res(finalResponse);
});
});
}
/**
* Get thumbnail photos for the users.
* @param users List of users
*/
public getAndStoreUserThumbnailPhotos = async (users: IUserPickerInfo[], tempLibId: string): Promise<IAzFuncValues[]> => {
let retVals: IAzFuncValues[] = [];
return new Promise(async (res, rej) => {
let tempLibUrl: string = await this.getLibraryDetails(tempLibId);
await this.checkAndCreateFolder(tempLibUrl);
if (users && users.length > 0) {
let requests: any[] = [];
let finalResponse: any[] = [];
if (users.length > userBatchLimit) {
let chunkUserArr: any[] = chunk(users, userBatchLimit);
Promise.all(chunkUserArr.map(async chnkdata => {
requests = [];
chnkdata.map((user: IUserPickerInfo) => {
let upn: string = user.LoginName.split('|')[2];
requests.push({
id: `${user.LoginName}_1`,
method: 'GET',
responseType: 'blob',
headers: { "Content-Type": "image/jpeg" },
url: `/users/${upn}/photos/48x48/$value`
}, {
id: `${user.LoginName}_2`,
method: 'GET',
responseType: 'blob',
headers: { "Content-Type": "image/jpeg" },
url: `/users/${upn}/photos/96x96/$value`
}, {
id: `${user.LoginName}_3`,
method: 'GET',
responseType: 'blob',
headers: { "Content-Type": "image/jpeg" },
url: `/users/${upn}/photos/240x240/$value`
});
});
let photoReq: any = { requests: requests };
let graphRes: any = await this._graphClient.api('$batch').post(photoReq);
finalResponse.push(graphRes);
})).then(async () => {
retVals = await this.saveThumbnailPhotosInDocLib(finalResponse, tempLibUrl, "Manual");
});
} else {
users.map((user: IUserPickerInfo) => {
let upn: string = user.LoginName.split('|')[2];
requests.push({
id: `${user.LoginName}_1`,
method: 'GET',
responseType: 'blob',
headers: { "Content-Type": "image/jpeg" },
url: `/users/${upn}/photos/48x48/$value`
}, {
id: `${user.LoginName}_2`,
method: 'GET',
responseType: 'blob',
headers: { "Content-Type": "image/jpeg" },
url: `/users/${upn}/photos/96x96/$value`
}, {
id: `${user.LoginName}_3`,
method: 'GET',
responseType: 'blob',
headers: { "Content-Type": "image/jpeg" },
url: `/users/${upn}/photos/240x240/$value`
});
});
let photoReq: any = { requests: requests };
finalResponse.push(await this._graphClient.api('$batch').post(photoReq));
retVals = await this.saveThumbnailPhotosInDocLib(finalResponse, tempLibUrl, "Manual");
}
}
res(retVals);
});
}
/**
* Add thumbnails to the configured document library
*/
private saveThumbnailPhotosInDocLib = async (thumbnails: any[], tempLibName: string, scope: 'Manual' | 'Bulk'): Promise<IAzFuncValues[]> => {
let retVals: IAzFuncValues[] = [];
if (thumbnails && thumbnails.length > 0) {
if (scope === "Manual") {
thumbnails.map(res => {
if (res.responses && res.responses.length > 0) {
res.responses.map(async thumbnail => {
if (!thumbnail.body.error) {
let username: string = thumbnail.id.split('_')[0].split('|')[2];
let userFilename: string = username.replace(/[@.]/g, '_');
let filecontent = this.dataURItoBlob("data:image/jpg;base64," + thumbnail.body);
let partFileName = '';
retVals.push({
userid: username,
picturename: userFilename
});
if (thumbnail.id.indexOf('_1') > 0) partFileName = 'SThumb.jpg';
else if (thumbnail.id.indexOf('_2') > 0) partFileName = "MThumb.jpg";
else if (thumbnail.id.indexOf('_3') > 0) partFileName = "LThumb.jpg";
await sp.web.getFolderByServerRelativeUrl(decodeURI(`${tempLibName}/`))
.files
.add(decodeURI(`${tempLibName}/${userFilename}_` + partFileName), filecontent, true);
}
});
}
});
return retVals;
}
if (scope === "Bulk") {
return new Promise((res, rej) => {
let batch = sp.createBatch();
thumbnails.map(async thumbnail => {
let username: string = thumbnail.name.replace('.' + thumbnail.name.split('.').pop(), '');
let userFilename: string = username.replace(/[@.]/g, '_');
retVals.push({
userid: username,
picturename: userFilename
});
let filecontent_48 = this.dataURItoBlob(thumbnail.Thumb48);
sp.web.getFolderByServerRelativeUrl(decodeURI(`${tempLibName}/`))
.files.inBatch(batch)
.add(decodeURI(`${tempLibName}/${userFilename}_` + 'SThumb.jpg'), filecontent_48, true);
let filecontent_96 = this.dataURItoBlob(thumbnail.Thumb96);
sp.web.getFolderByServerRelativeUrl(decodeURI(`${tempLibName}/`))
.files.inBatch(batch)
.add(decodeURI(`${tempLibName}/${userFilename}_` + 'MThumb.jpg'), filecontent_96, true);
let filecontent_240 = this.dataURItoBlob(thumbnail.Thumb240);
sp.web.getFolderByServerRelativeUrl(decodeURI(`${tempLibName}/`))
.files.inBatch(batch)
.add(decodeURI(`${tempLibName}/${userFilename}_` + 'LThumb.jpg'), filecontent_240, true);
});
batch.execute().then(() => { res(retVals); });
});
}
}
}
/**
* Generate 3 different thumbnails and upload to the temp library.
*/
public generateAndStorePhotoThumbnails = async (fileInfo: any[], tempLibId: string): Promise<IAzFuncValues[]> => {
return new Promise(async (res, rej) => {
if (fileInfo && fileInfo.length > 0) {
let tempLibUrl: string = await this.getLibraryDetails(tempLibId);
Promise.all(fileInfo.map(async file => {
file['Thumb48'] = await imgResize_48.play(URL.createObjectURL(file));
file['Thumb96'] = await imgResize_96.play(URL.createObjectURL(file));
file['Thumb240'] = await imgResize_240.play(URL.createObjectURL(file));
})).then(async () => {
let users: any = await this.saveThumbnailPhotosInDocLib(fileInfo, tempLibUrl, "Bulk");
res(users);
}).catch(err => {
console.log("Error while generating thumbnails: ", err);
res([]);
});
}
});
}
/**
* 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 (): Promise<any[]> => {
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();
}
/**
* Azure function to update the UPS Photo 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");
}
/**
* 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_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.addChoice('SyncType', ['Manual', 'Bulk'], ChoiceFieldFormatType.Dropdown, false, { Required: true, Description: 'Type of data sent to Azure function.' });
await listExists.fields.addMultilineText('ErrorMessage', 6, false, false, false, false, { Required: false, Description: 'Store the error message while calling 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('SyncType');
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).move('ID', 0);
await batch.execute();
}
}

View File

@ -0,0 +1,193 @@
import * as React from 'react';
import { useEffect, useState, useContext } from 'react';
import { useBoolean } from '@uifabric/react-hooks';
import styles from './PhotoSync.module.scss';
import * as strings from 'PhotoSyncWebPartStrings';
import { AppContext, AppContextProps } from '../common/AppContext';
import MessageContainer from '../common/MessageContainer';
import { MessageScope, IUserPickerInfo, IAzFuncValues, SyncType } from '../common/IModel';
import { PrimaryButton } from 'office-ui-fabric-react/lib/components/Button';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
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 { useDropzone } from 'react-dropzone';
import { css } from 'office-ui-fabric-react/lib/Utilities';
const map: any = require('lodash/map');
const find: any = require('lodash/find');
const filter: any = require('lodash/filter');
const uniqBy: any = require('lodash/uniqBy');
export interface IBulkPhotoSyncProps {
updateSPWithPhoto: (data: IAzFuncValues[], itemid: number) => void;
}
const BulkPhotoSync: React.FC<IBulkPhotoSyncProps> = (props) => {
const appContext: AppContextProps = useContext(AppContext);
const [loading, { setTrue: showLoading, setFalse: hideLoading }] = useBoolean(false);
const [columns, setColumns] = useState<IColumn[]>([]);
const [showUpdateButton, { setTrue: enableUpdateButton, setFalse: hideUpdateButton }] = useBoolean(false);
const [processingPhotoUpdate, { setTrue: showPhotoUpdateProcessing, setFalse: hidePhotoUpdateProcessing }] = useBoolean(false);
const [disableUpload, { toggle: toggleDisableUpload }] = useBoolean(false);
const [message, setMessage] = useState<any>({ Message: '', Scope: MessageScope.Info });
const [clearItems, setclearItems] = useState<boolean>(false);
const { getRootProps, getInputProps, fileRejections, acceptedFiles } = useDropzone({
accept: 'image/jpeg, image/jpg, image/png',
disabled: disableUpload,
noClick: disableUpload,
noDrag: disableUpload,
noDragEventsBubbling: disableUpload,
noKeyboard: disableUpload
});
const StatusRender = (childprops) => {
switch (childprops.Status.toLowerCase()) {
case 'valid':
return (
<div className={css(styles.fieldContent, styles.greenbgColor)}>
<span className={css(styles.spnContent, styles.greenBox)}>{childprops.Status}</span>
</div>
);
case 'invalid':
return (
<div className={css(styles.fieldContent, styles.redbgColor)}>
<span className={css(styles.spnContent, styles.redBox)}>{childprops.Status}</span>
</div>
);
}
};
/**
* Build columns for Datalist.
*/
const _buildColumns = () => {
let cols: IColumn[] = [];
let col: string = 'path';
cols.push({
key: 'loginname', name: 'User ID', fieldName: col, minWidth: 250, maxWidth: 350,
onRender: (item: any) => {
return (<div className={styles.fieldCustomizer}>{item[col].replace('.' + item[col].split('.').pop(), '')}</div>);
}
} as IColumn);
cols.push({
key: 'usertitle', name: 'Title', fieldName: 'title', minWidth: 250, maxWidth: 350,
onRender: (item: any, index: number, column: IColumn) => {
const authorPersona: IPersonaSharedProps = {
imageUrl: `/_layouts/15/userphoto.aspx?Size=S&username=${item.name.replace('.' + item.name.split('.').pop(), '')}`,
text: item.title,
className: styles.divPersona
};
return (
<div className={styles.fieldCustomizer}><Persona {...authorPersona} size={PersonaSize.size24} /></div>
);
}
} as IColumn);
cols.push({
key: 'preview', name: 'Photo', fieldName: col, minWidth: 100, maxWidth: 100,
onRender: (item: any, index: number, column: IColumn) => {
return (
<div className={styles.fieldCustomizer}>
<img style={{ width: '50px' }} src={URL.createObjectURL(item)} />
</div>
);
}
} as IColumn);
cols.push({
key: 'status', name: 'Status', fieldName: 'status', minWidth: 250, maxWidth: 350,
onRender: (item: any) => {
return (<div className={styles.fieldCustomizer}><StatusRender Status={item.status} /></div>);
}
} as IColumn);
setColumns(cols);
};
const _listUploadedFiles = async () => {
if (acceptedFiles.length > 0) {
showLoading();
let userids: string[] = map(acceptedFiles, (o) => { return o.name.replace('.' + o.name.split('.').pop(), ''); });
let userinfo: any[] = await appContext.helper.getUsersInfo(userids);
console.log(userinfo);
if (userinfo && userinfo.length > 0) {
userinfo.map((user: any) => {
let fil: any = find(acceptedFiles, (o) => { return o.name.replace('.' + o.name.split('.').pop(), '') == user.loginname; });
if (fil) {
fil['title'] = user.title;
fil['status'] = user.status;
}
});
}
_buildColumns();
hideLoading();
hidePhotoUpdateProcessing();
enableUpdateButton();
}
};
/**
* To generate the photo thumbnails and upload to the temp library.
* To send the updated final json to the Azure function to trigger the job for photo sync
*/
const _syncPhotoToSPUPS = async () => {
showPhotoUpdateProcessing();
toggleDisableUpload();
let finalFiles = filter(acceptedFiles, (o) => { return o.status.toLowerCase() == "valid"; });
console.log(finalFiles);
let userVals: IAzFuncValues[] = await appContext.helper.generateAndStorePhotoThumbnails(finalFiles, appContext.tempLib);
let itemID = await appContext.helper.createSyncItem(SyncType.Bulk);
await props.updateSPWithPhoto(uniqBy(userVals, 'userid'), itemID);
toggleDisableUpload();
hidePhotoUpdateProcessing();
hideUpdateButton();
setclearItems(true);
setMessage({ Message: strings.UpdateProcessInitialized, Scope: MessageScope.Success });
};
useEffect(() => {
setMessage({ Message: '' });
setclearItems(false);
_listUploadedFiles();
}, [acceptedFiles]);
return (
<div>
<div style={{ margin: '5px 0px' }}>
<MessageContainer MessageScope={MessageScope.Info} Message={strings.BulkSyncNote} />
</div>
<section className={styles.dropZoneContainer}>
<div {...getRootProps({ className: css(styles.dropzone, disableUpload ? styles.dropZonedisabled : '') })}>
<input {...getInputProps()} />
<p>{strings.BulkPhotoDragDrop}</p>
</div>
</section>
{loading &&
<ProgressIndicator label="Loading Photos..." description="Please wait..." />
}
{!loading && message && message.Message && message.Message.length > 0 &&
<MessageContainer MessageScope={message.Scope} Message={message.Message} />
}
{!loading && !clearItems && acceptedFiles && acceptedFiles.length > 0 &&
<>
<div className={styles.detailsListContainer}>
<DetailsList
items={clearItems ? [] : acceptedFiles}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true} />
</div>
{showUpdateButton &&
<div style={{ marginTop: "5px" }}>
<PrimaryButton text={strings.BtnUpdatePhoto} onClick={_syncPhotoToSPUPS} disabled={processingPhotoUpdate} />
{processingPhotoUpdate && <Spinner className={styles.generateTemplateLoader} label={strings.PropsLoader} ariaLive="assertive" labelPosition="right" />}
</div>
}
</>
}
</div>
);
};
export default BulkPhotoSync;

View File

@ -0,0 +1,168 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.photoSync {
.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);
}
.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;
}
.generateTemplateLoader {
display: inline-flex;
margin-top: 5px;
margin-left: 5px;
}
.note {
font-size: 13px;
font-family: initial;
margin-top: 5px;
font-style: italic;
}
.noPhotoMsg {
font-weight: bold;
color: red;
position: relative;
top: 30%;
}
.detailsListContainer {
margin-top: 5px;
max-height: 500px;
overflow-y: auto;
}
.dropZonedisabled {
border-color: #CCC !important;
background-color: lightgrey !important;
cursor: not-allowed;
}
.dropZoneContainer {
display: flex;
flex-direction: column;
font-family: sans-serif;
.dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
border-width: 2px;
border-radius: 3px;
border-color: "[theme:themeLighter]";
border-style: dashed;
background-color: "[theme:themeLighter]";
color: "[theme:themeWhite]";
outline: none;
font-weight: bold;
transition: border .24s ease-in-out;
&:hover, &::after {
border-width: 2px;
border-style: dashed;
border-color: "[theme:themePrimary]";
transition: border .24s ease-in-out;
}
}
.thumbsContainer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 16px;
.thumb {
display: inline-flex;
border-radius: 2;
border: 1px solid #eaeaea;
margin-bottom: 8px;
margin-right: 8px;
width: 100px;
height: 100px;
padding: 4px;
box-sizing: border-box;
.thumbInner {
display: flex;
min-width: 0;
overflow: hidden;
img {
display: block;
width: auto;
height: 100%;
}
}
}
}
}
}
.fieldCustomizer {
min-height: 30px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
.divPersona {
display: inline-flex;
align-items: center;
height: 30px;
overflow: hidden;
border-radius: 12px;
padding-right: 8px;
margin: 2px;
background-color: #edebe9;
}
.divPersona div:nth-child(2) {
padding: 0px 5px !important;
margin-bottom: 2px;
}
.fieldContent {
box-sizing: border-box;
padding: 4px 8px 5px 8px;
display: flex;
border-radius: 16px;
min-height: 24px;
align-items: center;
overflow: hidden;
margin-right: 4px;
.spnContent {
overflow: hidden;
text-overflow: ellipsis;
padding: 0 2px;
white-space: nowrap;
}
}
.fieldContent.redbgColor {background-color: #fbd3d3;}
.fieldContent.greenbgColor {background-color: #baf3ba;}
.fieldContent.orangebgColor {background-color: #ffc1ae;}
.fieldContent.bluebgColor {background-color: #c8faff;}
.fieldContent.purplebgColor {background-color: #c0b1e6;}
.fieldContent.yellowbgColor {background-color: #fbe3c4;}
}
.redBox {color: rgb(161, 10, 10);}
.greenBox{color: rgb(7, 133, 7);}
.orangeBox {color: #e35e32;}
.blueBox {color: #0c90b1;}
.purpleBox {color: #2d0988;}
.yellowBox {color: rgb(126, 75, 9);}
.searchcontainer {
width: 95%;
display: inline-block;
}
.refreshContainer {
display: inline-flex;
margin-top: 6px;
margin-left: 5px;
position: absolute;
}

View File

@ -0,0 +1,213 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import styles from './PhotoSync.module.scss';
import * as strings from 'PhotoSyncWebPartStrings';
import { HttpClient } from '@microsoft/sp-http';
import { DisplayMode } from '@microsoft/sp-core-library';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { Pivot, PivotItem } from 'office-ui-fabric-react/lib/Pivot';
import { AppContext, AppContextProps } from '../common/AppContext';
import { IHelper } from '../common/helper';
import ConfigPlaceholder from '../common/ConfigPlaceholder';
import { IPropertyFieldGroupOrPerson } from '@pnp/spfx-property-controls/lib/propertyFields/peoplePicker';
import MessageContainer from '../common/MessageContainer';
import { MessageScope, IUserInfo, IAzFuncValues } from '../common/IModel';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import UserSelectionSync from './UserSelectionSync';
import BulkPhotoSync from './BulkPhotoSync';
import SyncJobs from './SyncJobs';
const map: any = require('lodash/map');
export interface IPhotoSyncProps {
context: WebPartContext;
httpClient: HttpClient;
siteUrl: string;
domainName: string;
helper: IHelper;
displayMode: DisplayMode;
useFullWidth: boolean;
appTitle: string;
updateProperty: (value: string) => void;
AzFuncUrl: string;
UseCert: boolean;
dateFormat: string;
allowedUsers: IPropertyFieldGroupOrPerson[];
openPropertyPane: () => void;
enableBulkUpdate: boolean;
tempLib: string;
deleteThumbnails: boolean;
}
const PhotoSync: React.FunctionComponent<IPhotoSyncProps> = (props) => {
const [loading, setLoading] = useState<boolean>(true);
const [accessDenied, setAccessDenied] = useState<boolean>(false);
const [listExists, setListExists] = useState<boolean>(false);
const [selectedMenu, setSelectedMenu] = useState<string>('0');
const [pivotItems, setPivotItems] = useState<any[]>([]);
const [disablePivot, setdisablePivot] = useState<boolean>(false);
const headerButtonProps = { 'disabled': disablePivot };
const parentCtxValues: AppContextProps = {
context: props.context,
siteurl: props.siteUrl,
domainName: props.domainName,
helper: props.helper,
displayMode: props.displayMode,
openPropertyPane: props.openPropertyPane,
tempLib: props.tempLib,
deleteThumbnails: props.deleteThumbnails
};
const showConfig = !props.tempLib; //!props.templateLib || !props.AzFuncUrl || !props.tempLib ? true : false;
const _useFullWidth = () => {
const jQuery: any = require('jquery');
if (props.useFullWidth) {
jQuery("#workbenchPageContent").prop("style", "max-width: none");
jQuery(".SPCanvas-canvas").prop("style", "max-width: none");
jQuery(".CanvasZone").prop("style", "max-width: none");
} else {
jQuery("#workbenchPageContent").prop("style", "max-width: 924px");
}
};
const _checkAndCreateLists = async () => {
setLoading(false);
let listCheck: boolean = await props.helper.checkAndCreateLists();
if (listCheck) setListExists(true);
};
const _checkForAccess = async () => {
setLoading(true);
let currentUserInfo: IUserInfo = await props.helper.getCurrentUserCustomInfo();
if (currentUserInfo.IsSiteAdmin) {
_checkAndCreateLists();
} else {
let allowedGroups: string[] = map(props.allowedUsers, 'login');
let accessAllowed: boolean = props.helper.checkCurrentUserGroup(allowedGroups, currentUserInfo.Groups);
console.log("Access allowed: ", accessAllowed);
if (accessAllowed) {
_checkAndCreateLists();
} else {
setLoading(false);
setAccessDenied(true);
}
}
};
const _updatePivotMenus = () => {
let pvitems: any[] = [];
if (props.enableBulkUpdate) {
pvitems = [
<PivotItem headerText={strings.TabMenu2} itemKey="1" itemIcon="BulkUpload" headerButtonProps={headerButtonProps}></PivotItem>,
];
}
setPivotItems(pvitems);
};
const _onMenuClick = (item?: PivotItem, ev?: React.MouseEvent<HTMLElement, MouseEvent>): void => {
if (item) {
if (item.props.itemKey == "0") {
} else if (item.props.itemKey == "1") {
}
setSelectedMenu(item.props.itemKey);
}
};
const _prepareJSONForAzFunc = (data: IAzFuncValues[], itemid: number, folderPath: string): string => {
let finalJson: string = "";
let tenantName: string = props.siteUrl.split("." + props.domainName)[0];
if (data && data.length > 0) {
let userPhotoObj = new Object();
userPhotoObj['adminurl'] = `${tenantName}-admin.${props.domainName}`;
userPhotoObj['mysiteurl'] = `${tenantName}-my.${props.domainName}`;
userPhotoObj['targetSiteUrl'] = props.siteUrl;
userPhotoObj['picfolder'] = folderPath + "/";
userPhotoObj['clearPhotos'] = props.deleteThumbnails;
userPhotoObj['usecert'] = props.UseCert ? props.UseCert : false;
userPhotoObj['itemId'] = itemid;
userPhotoObj['value'] = data;
finalJson = JSON.stringify(userPhotoObj);
}
return finalJson;
};
const _updateSPWithPhoto = async (data: IAzFuncValues[], itemid: number) => {
setdisablePivot(true);
let tempFolderPath: string = await props.helper.getLibraryDetails(props.tempLib);
let finalJson: string = _prepareJSONForAzFunc(data, itemid, tempFolderPath);
await props.helper.updateSyncItem(itemid, finalJson);
props.helper.runAzFunction(props.httpClient, finalJson, props.AzFuncUrl, itemid);
setdisablePivot(false);
};
useEffect(() => {
_useFullWidth();
}, [props.useFullWidth]);
useEffect(() => {
_checkForAccess();
}, [props.allowedUsers]);
useEffect(() => {
_updatePivotMenus();
}, [props.enableBulkUpdate]);
return (
<AppContext.Provider value={parentCtxValues}>
<div className={styles.photoSync}>
<div className={styles.container}>
<div className={styles.row}>
<div className={styles.column}>
<WebPartTitle displayMode={props.displayMode} title={props.appTitle ? props.appTitle : strings.DefaultAppTitle} updateProperty={props.updateProperty} />
{showConfig ? (
<ConfigPlaceholder />
) : (
<>
{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>
<Pivot defaultSelectedKey="0" selectedKey={selectedMenu} onLinkClick={_onMenuClick} className={styles.periodmenu}>
<PivotItem headerText={strings.TabMenu1} itemKey="0" itemIcon="SchoolDataSyncLogo" headerButtonProps={headerButtonProps}></PivotItem>
{pivotItems}
<PivotItem headerText={strings.TabMenu3} itemKey="2" itemIcon="SyncStatus" headerButtonProps={headerButtonProps}></PivotItem>
</Pivot>
</div>
{/* Individual Selection photo sync */}
{selectedMenu == "0" &&
<div>
<UserSelectionSync updateSPWithPhoto={_updateSPWithPhoto} />
</div>
}
{/* Bulk photo sync */}
{selectedMenu == "1" &&
<BulkPhotoSync updateSPWithPhoto={_updateSPWithPhoto} />
}
{/* Overall status of the sync jobs */}
{selectedMenu == "2" &&
<SyncJobs dateFormat={props.dateFormat} />
}
</>
)}
</>
)}
</>
)}
</>
)}
</div>
</div>
</div>
</div>
</AppContext.Provider>
);
};
export default PhotoSync;

View File

@ -0,0 +1,93 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import * as strings from 'PhotoSyncWebPartStrings';
import { DetailsList, IColumn, DetailsListLayoutMode, ConstrainMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import MessageContainer from '../common/MessageContainer';
import { MessageScope } from '../common/IModel';
import { StatusRender, PersonaRender } from '../common/FieldRenderer';
const map: any = require('lodash/map');
const union: any = require('lodash/union');
export interface ISyncJobResultsProps {
data: string;
error: string;
}
const SyncJobResults: React.FC<ISyncJobResultsProps> = (props) => {
const [loading, setLoading] = React.useState<boolean>(true);
const [jobresults, setJobResults] = React.useState<any[]>([]);
const [columns, setColumns] = React.useState<IColumn[]>([]);
const [emptyMessage, setEmptyMessage] = useState<boolean>(false);
const _buildColumns = (colValues: string[]) => {
let cols: IColumn[] = [];
colValues.map(col => {
if (col.toLowerCase() == "userid") {
cols.push({
key: 'UserID', name: 'User ID', fieldName: col, minWidth: 300,
onRender: (item: any, index: number, column: IColumn) => {
return <PersonaRender Title={item[col]} UserID={item[col]} />;
}
} as IColumn);
} else {
cols.push({
key: col, name: col, fieldName: col, minWidth: 100, maxWidth: 250,
onRender: (item: any, index: number, column: IColumn) => {
return <StatusRender Value={item[col]} />;
}
} as IColumn);
}
});
setColumns(cols);
};
const _buildJobResults = () => {
if (props.error && props.error.length > 0) {
} else {
if (props.data && props.data.length > 0) {
let parsedResults = JSON.parse(props.data);
let colValues = ['userid', 'Status'];
_buildColumns(colValues);
setJobResults(parsedResults.value);
} else setEmptyMessage(true);
}
setLoading(false);
};
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}
/>
}
{props.error && props.error.length > 0 &&
<MessageContainer MessageScope={MessageScope.Failure} Message={`${strings.SyncFailedErrorMessage} ${props.error}`} />
}
{emptyMessage &&
<MessageContainer MessageScope={MessageScope.Info} Message={`${strings.EmptyTable}`} />
}
</div>
);
};
export default SyncJobResults;

View File

@ -0,0 +1,200 @@
import * as React from 'react';
import { useEffect, useState, useContext } from 'react';
import { useBoolean } from '@uifabric/react-hooks';
import styles from './PhotoSync.module.scss';
import * as strings from 'PhotoSyncWebPartStrings';
import { DetailsList, IColumn, DetailsListLayoutMode, ConstrainMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList';
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 { IIconProps } from 'office-ui-fabric-react/lib/Icon';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { Dialog } from 'office-ui-fabric-react/lib/components/Dialog/Dialog';
import { DialogType } from 'office-ui-fabric-react/lib/components/Dialog';
import * as moment from 'moment';
import { AppContext, AppContextProps } from '../common/AppContext';
import MessageContainer from '../common/MessageContainer';
import SyncJobResults from './SyncJobResults';
import { MessageScope } from '../common/IModel';
import { PersonaRender, StatusRender, SyncTypeRender } from '../common/FieldRenderer';
const orderBy: any = require('lodash/orderBy');
const filter: any = require('lodash/filter');
export interface ISyncJobsProps {
dateFormat: string;
}
const SyncJobs: React.FC<ISyncJobsProps> = (props) => {
const appContext: AppContextProps = useContext(AppContext);
const actionIcon: IIconProps = { iconName: 'InfoSolid' };
const refreshIcon: IIconProps = { iconName: 'Refresh' };
const [refreshLoading, setRefreshLoading] = useState<boolean>(false);
const [loading, { setTrue: showLoading, setFalse: hideLoading }] = useBoolean(true);
const [jobs, setJobs] = useState<any[]>([]);
const [columns, setColumns] = useState<IColumn[]>([]);
const [filItems, setFilItems] = useState<any[]>([]);
const [searchText, setSearchText] = useState<string>('');
const [hideDialog, setHideDialog] = React.useState<boolean>(true);
const [jobresults, setJobResults] = React.useState<string>('');
const [errorMsg, setErrorMessage] = React.useState<string>('');
const actionClick = (data) => {
setJobResults(data.SyncResults);
setErrorMessage(data.ErrorMessage);
setHideDialog(false);
};
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,
onRender: (item: any, index: number, column: IColumn) => {
return (<div className={styles.fieldCustomizer}>{item.ID}</div>);
}
} as IColumn);
cols.push({
key: 'Title', name: 'Title', fieldName: 'Title', minWidth: 100, maxWidth: 150,
onRender: (item: any, index: number, column: IColumn) => {
return (<div className={styles.fieldCustomizer}>{item.Title}</div>);
}
} as IColumn);
cols.push({
key: 'SyncType', name: 'Sync Type', fieldName: 'SyncType', minWidth: 100, maxWidth: 100,
onRender: (item: any, index: number, column: IColumn) => {
return <SyncTypeRender Value={item.SyncType} />;
}
} as IColumn);
cols.push({
key: 'Author', name: 'Author', fieldName: 'Author.Title', minWidth: 250, maxWidth: 250,
onRender: (item: any, index: number, column: IColumn) => {
return <PersonaRender Title={item["Author"].Title} UserID={item["Author"].EMail} />;
}
} as IColumn);
cols.push({
key: 'Created', name: 'Created', fieldName: 'Created', minWidth: 100, maxWidth: 100,
onRender: (item: any, index: number, column: IColumn) => {
return (<div className={styles.fieldCustomizer}>{moment(item.Created).format(props.dateFormat ? props.dateFormat : 'DD/MM/YYYY')}</div>);
}
} as IColumn);
cols.push({
key: 'Status', name: 'Status', fieldName: 'Status', minWidth: 100, maxWidth: 150,
onRender: (item: any, index: number, column: IColumn) => {
return <StatusRender Value={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 _onChangeSearchBox = (srchkey: string) => {
setSearchText(srchkey);
if (srchkey && srchkey.length > 0) {
let filtered: any[] = filter(jobs, (o) => {
return o.ID.toString().indexOf(srchkey.toLowerCase()) > -1 ||
o.Title.toLowerCase().indexOf(srchkey.toLowerCase()) > -1 || o['Author'].Title.toLowerCase().indexOf(srchkey.toLowerCase()) > -1 ||
o.Status.toLowerCase().indexOf(srchkey.toLowerCase()) > -1 || o.SyncType.toLowerCase().indexOf(srchkey.toLowerCase()) > -1;
});
setFilItems(filtered);
} else setFilItems(jobs);
};
const _loadJobsList = async () => {
let jobsList: any[] = await appContext.helper.getAllJobs();
jobsList = orderBy(jobsList, ['ID'], ['desc']);
setJobs(jobsList);
setFilItems(jobsList);
};
const _buildJobsList = async () => {
_buildColumns();
await _loadJobsList();
hideLoading();
};
const _refreshList = async () => {
setRefreshLoading(true);
await _loadJobsList();
setRefreshLoading(false);
};
const _closeDialog = () => {
setHideDialog(true);
};
useEffect(() => {
_buildJobsList();
}, []);
return (
<div>
{loading ? (
<ProgressIndicator label={strings.PropsLoader} description={strings.JobsListLoaderDesc} />
) : (
<div className="ms-Grid-row" style={{ marginBottom: '5px', paddingLeft: '18px' }}>
<div className={styles.searchcontainer}>
<SearchBox
placeholder={`Search...`}
onChange={_onChangeSearchBox}
underlined={true}
iconProps={{ iconName: 'Filter' }}
value={searchText}
autoFocus={false}
/>
</div>
<div className={styles.refreshContainer}>
<IconButton iconProps={refreshIcon} title="Refresh" ariaLabel="Refresh" onClick={_refreshList} disabled={refreshLoading} />
{refreshLoading &&
<Spinner size={SpinnerSize.small} />
}
</div>
{filItems && filItems.length > 0 ? (
<div style={{ overflowX: 'auto' }}>
<DetailsList
items={filItems}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true}
/>
</div>
) : (
<MessageContainer MessageScope={MessageScope.Info} Message={strings.EmptyTable} />
)}
</div>
)}
<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 data={jobresults} error={errorMsg} />
</Dialog>
</div>
);
};
export default SyncJobs;

View File

@ -0,0 +1,203 @@
import * as React from 'react';
import { useEffect, useState, useContext } from 'react';
import { useBoolean } from '@uifabric/react-hooks';
import styles from './PhotoSync.module.scss';
import * as strings from 'PhotoSyncWebPartStrings';
import { PeoplePicker, PrincipalType } from "@pnp/spfx-controls-react/lib/PeoplePicker";
import { MessageScope, IUserPickerInfo, IAzFuncValues, SyncType } from '../common/IModel';
import { PrimaryButton } from 'office-ui-fabric-react/lib/components/Button';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
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 { AppContext, AppContextProps } from '../common/AppContext';
import MessageContainer from '../common/MessageContainer';
const filter: any = require('lodash/filter');
const map: any = require('lodash/map');
const uniqBy: any = require('lodash/uniqBy');
export interface IUserSelectionSyncProps {
updateSPWithPhoto: (data: IAzFuncValues[], itemid: number) => void;
}
const UserSelectionSync: React.FunctionComponent<IUserSelectionSyncProps> = (props) => {
const appContext: AppContextProps = useContext(AppContext);
const [selectedUsers, setSelectedUsers] = useState<any[]>([]);
const [showPhotoLoader, { toggle: togglePhotoLoader, setFalse: hidePhotoLoader }] = useBoolean(false);
const [disableButton, { toggle: toggleDisableButton, setFalse: enableButton }] = useBoolean(false);
const [disableUserPicker, { toggle: toggleDisableUserPicker }] = useBoolean(false);
const [columns, setColumns] = useState<IColumn[]>([]);
const [processingPhotoUpdate, { toggle: toggleProcessingPhotoUpdate }] = useBoolean(false);
const [showUpdateButton, { toggle: toggleShowUpdateButton, setFalse: hideUpdateButton }] = useBoolean(false);
const [message, setMessage] = useState<any>({ Message: '', Scope: MessageScope.Info });
/**
* Build columns for Datalist.
*/
const _buildColumns = (colValues: string[]) => {
let cols: IColumn[] = [];
colValues.map(col => {
if (col.toLowerCase() == "title") {
cols.push({
key: 'title', name: 'Title', fieldName: col, minWidth: 150, maxWidth: 200,
} as IColumn);
}
if (col.toLowerCase() == "loginname") {
cols.push({
key: 'loginname', name: 'User ID', fieldName: col, minWidth: 250, maxWidth: 350,
onRender: (item: any) => {
return (<span>{item[col].split('|')[2]}</span>);
}
} as IColumn);
}
if (col.toLowerCase() == "photourl") {
cols.push({
key: 'photourl', name: 'SP Profile Photo', fieldName: col, minWidth: 100, maxWidth: 100,
onRender: (item: any, index: number, column: IColumn) => {
const authorPersona: IPersonaSharedProps = {
imageUrl: item[col],
};
return (
<div><Persona {...authorPersona} size={PersonaSize.large} /></div>
);
}
} as IColumn);
}
if (col.toLowerCase() == "aadphotourl") {
cols.push({
key: 'aadphotourl', name: 'Azure Profile Photo', fieldName: col, minWidth: 100, maxWidth: 100,
onRender: (item: any, index: number, column: IColumn) => {
if (item[col]) {
const authorPersona: IPersonaSharedProps = {
imageUrl: item[col],
};
return (
<div><Persona {...authorPersona} size={PersonaSize.large} /></div>
);
} else return (<div className={styles.noPhotoMsg}>{strings.EmptyPhotoMsg}</div>);
}
} as IColumn);
}
});
setColumns(cols);
};
/**
* People Picker change event
*/
const _selectedItems = (items: any[]) => {
let userInfo: IUserPickerInfo[] = [];
if (items && items.length > 0) {
items.map(item => {
userInfo.push({
Title: item.text,
LoginName: item.loginName,
PhotoUrl: item.imageUrl
});
});
_buildColumns(Object.keys(userInfo[0]));
}
setSelectedUsers(userInfo);
enableButton();
hideUpdateButton();
};
/**
* Set the defaultusers property for people picker control, this is used when clearing the data.
*/
const _getSelectedUsersLoginNames = (items: any[]): string[] => {
let retUsers: string[] = [];
retUsers = map(items, (o) => { return o.LoginName.split('|')[2]; });
return retUsers;
};
/**
* To display the photos from Azure AD
*/
const _getPhotosFromAzure = async () => {
toggleDisableUserPicker();
toggleDisableButton();
togglePhotoLoader();
let res: any[] = await appContext.helper.getUserPhotoFromAADForDisplay(selectedUsers);
if (res && res.length > 0) {
let tempUsers: IUserPickerInfo[] = selectedUsers;
res.map(response => {
if (response.responses && response.responses.length > 0) {
response.responses.map(finres => {
var fil = filter(tempUsers, (o) => { return o.LoginName == finres.id; });
if (fil && fil.length > 0) {
fil[0].AADPhotoUrl = finres.body.error ? '' : "data:image/jpg;base64," + finres.body;
}
});
}
});
setSelectedUsers(tempUsers);
_buildColumns(Object.keys(tempUsers[0]));
}
toggleDisableUserPicker();
togglePhotoLoader();
toggleShowUpdateButton();
setMessage({Message: strings.NoAADPhotos, Scope: MessageScope.Info});
};
/**
* To download the photo thumbnails from Azure to document library.
* To send the updated final json to the Azure function to trigger the job for photo sync
*/
const _syncPhotoToSPUPS = async () => {
toggleProcessingPhotoUpdate();
let finalUsers: any[] = filter(selectedUsers, (o) => { return o.AADPhotoUrl; });
let userVals: IAzFuncValues[] = await appContext.helper.getAndStoreUserThumbnailPhotos(finalUsers, appContext.tempLib);
let itemID = await appContext.helper.createSyncItem(SyncType.Manual);
await props.updateSPWithPhoto(uniqBy(userVals, 'userid'), itemID);
setSelectedUsers([]);
toggleProcessingPhotoUpdate();
setMessage({ Message: strings.UpdateProcessInitialized, Scope: MessageScope.Success });
};
return (
<div>
{message && message.Message && message.Message.length > 0 &&
<MessageContainer MessageScope={message.Scope} Message={message.Message} />
}
<PeoplePicker
disabled={disableUserPicker || processingPhotoUpdate}
context={appContext.context}
titleText={strings.PPLPickerTitleText}
personSelectionLimit={10}
groupName={""} // Leave this blank in case you want to filter from all users
showtooltip={false}
isRequired={false}
showHiddenInUI={false}
principalTypes={[PrincipalType.User]}
resolveDelay={500}
selectedItems={_selectedItems}
defaultSelectedUsers={selectedUsers.length > 0 ? _getSelectedUsersLoginNames(selectedUsers) : []}
/>
{selectedUsers && selectedUsers.length > 0 &&
<>
<div style={{ marginTop: "5px" }}>
<PrimaryButton text={strings.BtnAzurePhotoProps} onClick={_getPhotosFromAzure} disabled={disableButton || processingPhotoUpdate} />
{showPhotoLoader && <Spinner className={styles.generateTemplateLoader} label={strings.PropsLoader} ariaLive="assertive" labelPosition="right" />}
</div>
<div style={{ marginTop: '5px' }}>
<DetailsList
items={selectedUsers}
setKey="set"
columns={columns}
compact={true}
layoutMode={DetailsListLayoutMode.justified}
constrainMode={ConstrainMode.unconstrained}
isHeaderVisible={true}
selectionMode={SelectionMode.none}
enableShimmer={true} />
</div>
{showUpdateButton &&
<div style={{ marginTop: "5px" }}>
<PrimaryButton text={strings.BtnUpdatePhoto} onClick={_syncPhotoToSPUPS} disabled={processingPhotoUpdate} />
{processingPhotoUpdate && <Spinner className={styles.generateTemplateLoader} label={strings.PropsLoader} ariaLive="assertive" labelPosition="right" />}
</div>
}
</>
}
</div>
);
};
export default UserSelectionSync;

View File

@ -0,0 +1,59 @@
define([], function () {
return {
PropertyPaneDescription: "",
BasicGroupName: "Configurations",
ListCreationText: "Verifying the required list...",
PropTemplateLibLabel: "Select a library to store the templates",
PropTempLibLabel: "Select a library to store the thumbnail photos",
PropDelThumbnail: "Turn on to delete the thumbnail stored",
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",
PropDelThumbnailCallout: "Option to delete the thumbnail stored. You can also turn off and use the thumbnail photos for other purpose.",
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.",
PropEnableBUCallout: "Turn on to enable bulk update",
PropInfoTempLib: "Document library used to store the user profile thumbnails. You can opt for the automatic removal of thumbnail by turning 'On' the below setting.",
DefaultAppTitle: "SharePoint Profile Photo Sync",
PlaceholderIconText: "Configure the settings",
PlaceholderDescription: "Use the configuration settings to map the document library, azure function and other settings.",
PlaceholderButtonLabel: "Configure",
AccessCheckDesc: "Checking for access...",
SitePrivilegeCheckLabel: "Checking site admin privilege...",
BtnUploadPhotoDataForSync: "Upload Data to Sync",
BtnUpdatePhotoProps: "Update User Properties",
BtnAzurePhotoProps: "Get Photo from Azure AD",
BtnUpdatePhoto: "Update User's Photo",
PPLPickerTitleText: "Select users to sync their photos!",
Photo_UserListChanges: "Changes in user list, please remove the user from the table manually or reinitialize or get the photo again!",
Photo_UserListEmpty: "Since all the users have been removed, the table has been cleared!",
PropsLoader: "Please wait...",
PropsUpdateLoader: "Please wait, initializing the job to update the properties",
AdminConfigHelp: "Please contact your site administrator to configure the webpart.",
AccessDenied: "Access denied. Please contact your administrator.",
NoAADPhotos: "User Photos that are not updated in Azure AD are skipped in the update process.",
UpdateProcessInitialized: "Sync Job triggered to update the photos. Track the status on 'Sync Status' tab!",
EmptyPhotoMsg: "Photo not found!",
BulkSyncNote: "The photo filename should be in the format 'LoginID.png'. Supported image types are '.png', '.jpeg', '.jpg'. Photo(s) with invalid filename will not be processed.",
BulkPhotoDragDrop: "Drag 'n' drop the photos, or click to select the photos",
JobsListLoaderDesc: "Loading the jobs list...",
EmptyTable: "Sorry, no data to be displayed!",
JobResultsDialogTitle: "Users list with photo updated!",
JobResultsLoaderDesc: "Loading the results...",
SyncFailedErrorMessage: "Oops, there is an error while updating the properties. Error Message:",
TabMenu1: "User Selection Photo Sync",
TabMenu2: "Bulk Sync",
//TabMenu3: "Bulk Files Uploaded",
// TabMenu4: "Templates Generated",
TabMenu3: "Sync Status"
}
});

View File

@ -0,0 +1,62 @@
declare interface IPhotoSyncWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
ListCreationText: string;
PropTemplateLibLabel: string;
PropTempLibLabel: string;
PropDelThumbnail: string;
PropAzFuncLabel: string;
PropAzFuncDesc: string;
PropUseCertLabel: string;
PropUseCertCallout: string;
PropDelThumbnailCallout: string;
PropDateFormatLabel: string;
PropInfoDateFormat: string;
PropInfoUseCert: string;
PropInfoTemplateLib: string;
PropInfoNormalUser: string;
PropAllowedUserInfo: string;
PropEnableBUCallout: string;
PropInfoTempLib: string;
DefaultAppTitle: string;
PlaceholderIconText: string;
PlaceholderDescription: string;
PlaceholderButtonLabel: string;
AccessCheckDesc: string;
SitePrivilegeCheckLabel: string;
BtnUploadPhotoDataForSync: string;
BtnUpdatePhotoProps: string;
BtnAzurePhotoProps: string;
BtnUpdatePhoto: string;
PPLPickerTitleText: string;
Photo_UserListChanges: string;
Photo_UserListEmpty: string;
PropsLoader: string;
PropsUpdateLoader: string;
AdminConfigHelp: string;
AccessDenied: string;
NoAADPhotos: string;
UpdateProcessInitialized: string;
EmptyPhotoMsg: string;
BulkSyncNote: string;
BulkPhotoDragDrop: string;
JobsListLoaderDesc: string;
EmptyTable: string;
JobResultsDialogTitle: string;
JobResultsLoaderDesc: string;
SyncFailedErrorMessage: string;
TabMenu1: string;
TabMenu2: string;
TabMenu3: string;
TabMenu4: string;
TabMenu5: string;
}
declare module 'PhotoSyncWebPartStrings' {
const strings: IPhotoSyncWebPartStrings;
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
}
}