Merge pull request #1847 from joaojmendes/react-organization-chart

This commit is contained in:
Hugo Bernier 2021-05-03 22:30:17 -04:00 committed by GitHub
commit bb5c46ce58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 23507 additions and 0 deletions

View File

@ -0,0 +1,27 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"react": {
"version": "detect"
}
},
"ignorePatterns": ["*.js"],
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended"
]
}

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,11 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "onprem19",
"version": "1.12.0",
"libraryName": "react-organization-chart",
"libraryId": "0b4a3e5d-123f-41ea-96c4-538c6a19932b",
"packageManager": "npm",
"componentType": "webpart"
}
}

View File

@ -0,0 +1,69 @@
# Organization Chart
## Summary
This web part shows an organization chart based on specified user, and user can navigate to show company organization. This web part can be installed on SharePoint Server 2019, and SharePoint Online.
![Organization Chart](./assets/orgchart_02.jpg)
![Organization Chart](./assets/orgchart_01.jpg)
![Organization Chart](./assets/orgchart.gif)
## Compatibility
![SPFx 1.4.1](https://img.shields.io/badge/SPFx-1.4.1-green.svg)
![Node.js LTS 6.x | LTS 8.x](https://img.shields.io/badge/Node.js-LTS%206.x%20%7C%20LTS%208.x-green.svg)
![SharePoint 2019 | Online](https://img.shields.io/badge/SharePoint-2019%20%7C%20Online-yellow.svg)
![Teams No: Not designed for Microsoft Teams](https://img.shields.io/badge/Teams-No-red.svg "Not designed for Microsoft Teams")
![Workbench Hosted: Does not work with local workbench](https://img.shields.io/badge/Workbench-Hosted-yellow.svg "Does not work with local workbench")
## Applies to
- [SharePoint Framework](https://aka.ms/spfx)
## Solution
Solution|Author(s)
--------|---------
react-organisation-chart |[João Mendes](https://github.com/joaojmendes), Storm Technology, ([@joaojmendes](https://twitter.com/joaojmendes))
## Version history
Version|Date|Comments
-------|----|--------
1.0|May, 2021|Initial release
## Prerequisites
No pre-requisites
## Minimal Path to Awesome
- Clone this repository
- Ensure that you are at the solution folder
- in the command-line run:
- `npm install`
- `gulp serve`
## Features
This web part shows how to use PnPjs, Office-ui-fabric-react to create an Organization Chart
This extension illustrates the following concepts:
## 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.**
## Support
We do not support samples, but we do use GitHub to track issues and constantly want to improve these samples.
If you encounter any issues while using this sample, [create a new issue](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected&template=bug-report.yml&sample=react-organisation-chart&authors=@joaojmendes&title=react-organisation-chart%20-%20).
For questions regarding this sample, [create a new question](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected&template=question.yml&sample=react-organisation-chart&authors=@joaojmendes&title=react-organisation-chart%20-%20).
Finally, if you have an idea for improvement, [make a suggestion](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected&template=suggestion.yml&sample=react-organisation-chart&authors=@joaojmendes&title=react-organisation-chart%20-%20).
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-organization-chart" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View File

@ -0,0 +1,65 @@
[
{
"name": "pnp-sp-dev-spfx-web-parts-react-organization-chart",
"source": "pnp",
"title": "Organization Chart (SP2019 and Online)",
"shortDescription": "Shows an organization chart based on specified user, and user can navigate to show company organization",
"url": "https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-organization-chart",
"longDescription": [
"This web part shows an organization chart based on specified user, and user can navigate to show company organization.",
"Can be installed on SharePoint Server 2019, and SharePoint Online."
],
"creationDateTime": "2021-05-03",
"updateDateTime": "2021-05-03",
"products": [
"SharePoint",
"Office"
],
"metadata": [
{
"key": "CLIENT-SIDE-DEV",
"value": "React"
},
{
"key": "SPFX-VERSION",
"value": "1.4.1"
}
],
"thumbnails": [
{
"type": "image",
"order": 100,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-organization-chart/assets/orgchart.gif",
"alt": "Web Part Preview"
},
{
"type": "image",
"order": 101,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-organization-chart/assets/orgchart_01.jpg",
"alt": "Web Part Preview"
},
{
"type": "image",
"order": 102,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-organization-chart/assets/orgchart_02.jpg",
"alt": "Web Part Preview"
}
],
"authors": [
{
"gitHubAccount": "joaojmendes",
"company": "Storm Technology",
"pictureUrl": "https://github.com/joaojmendes.png",
"name": "João Mendes",
"twitter": "joaojmendes"
}
],
"references": [
{
"name": "Build your first SharePoint client-side web part",
"description": "Client-side web parts are client-side components that run in the context of a SharePoint page. Client-side web parts can be deployed to SharePoint environments that support the SharePoint Framework. You can also use modern JavaScript web frameworks, tools, and libraries to build them.",
"url": "https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/get-started/build-a-hello-world-web-part"
}
]
}
]

View File

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

View File

@ -0,0 +1,15 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "react-organization-chart",
"id": "0b4a3e5d-123f-41ea-96c4-538c6a19932b",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true
},
"paths": {
"zippedPackage": "solution/react-organization-chart.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,57 @@
// gulpfile.js
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
const merge = require('webpack-merge');
const TerserPlugin = require('terser-webpack-plugin-legacy');
build.addSuppression(/Warning - \[sass\] The local CSS class .* is not camelCase and will not be type-safe./gi);
// force use of projects specified typescript version
const typeScriptConfig = require('@microsoft/gulp-core-build-typescript/lib/TypeScriptConfiguration');
typeScriptConfig.TypeScriptConfiguration.setTypescriptCompiler(require('typescript'));
// disable tslint
build.tslint.enabled = false;
const eslint = require('gulp-eslint');
const eslintSubTask = build.subTask('eslint', function (gulp, buildOptions, done) {
return gulp.src(['src/**/*.{ts,tsx}'])
// eslint() attaches the lint output to the "eslint" property
// of the file object so it can be used by other modules.
.pipe(eslint())
// eslint.format() outputs the lint results to the console.
// Alternatively use eslint.formatEach() (see Docs).
.pipe(eslint.format())
// To have the process exit with an error code (1) on
// lint error, return the stream and pipe to failAfterError last.
.pipe(eslint.failAfterError());
});
build.rig.addPreBuildTask(build.task('eslint-task', eslintSubTask));
// force use of projects specified react version
build.configureWebpack.mergeConfig({
additionalConfiguration: (generatedConfiguration) => {
// force use of projects specified react version
generatedConfiguration.externals = generatedConfiguration.externals
.filter(name => !(["react", "react-dom"].includes(name)));
// force use TerserPlugIn (remove UglifyJs)
generatedConfiguration.plugins.forEach((plugin, i) => {
if (plugin.options && plugin.options.mangle) {
generatedConfiguration.plugins.splice(i, 1);
generatedConfiguration = merge(generatedConfiguration, {
plugins: [
new TerserPlugin()
]
});
}
});
return generatedConfiguration;
}
});
build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
{
"name": "react-organization-chart",
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@fluentui/theme": "^2.1.0",
"@microsoft/office-ui-fabric-react-bundle": "^1.11.0",
"@microsoft/sp-core-library": "~1.4.1",
"@microsoft/sp-lodash-subset": "~1.4.1",
"@microsoft/sp-office-ui-fabric-core": "^1.11.0",
"@microsoft/sp-tslint-rules": "^1.11.0",
"@microsoft/sp-webpart-base": "~1.4.1",
"@pnp/common": "^1.3.11",
"@pnp/logging": "^1.3.11",
"@pnp/odata": "^1.3.11",
"@pnp/sp": "^1.3.11",
"@pnp/spfx-controls-react": "^1.21.1",
"@pnp/spfx-property-controls": "^1.3.0",
"@uifabric/merge-styles": "^7.19.2",
"enhanced-resolve": "^5.8.0",
"idb-keyval": "^5.0.5",
"office-ui-fabric-react": "^6.214.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"spfx-uifabric-themes": "^0.8.5"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.4.1",
"@microsoft/sp-module-interfaces": "1.4.1",
"@microsoft/sp-webpart-workbench": "1.4.1",
"@types/chai": "3.4.34",
"@types/es6-promise": "0.0.33",
"@types/mocha": "2.2.38",
"@types/react": "^16.9.19",
"@types/react-dom": "^16.9.0",
"@types/webpack-env": "1.13.1",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"ajv": "~5.2.2",
"eslint": "^7.25.0",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.2.0",
"gulp": "~3.9.1",
"gulp-eslint": "^6.0.0",
"terser-webpack-plugin-legacy": "^1.2.3",
"typescript": "3.9.7",
"webpack-merge": "^4.2.1"
}
}

View File

@ -0,0 +1,88 @@
import { SPComponentLoader } from "@microsoft/sp-loader";
// get all the web's regional settings
const DEFAULT_PERSONA_IMG_HASH = "7ad602295f8386b7615b582d87bcc294";
const DEFAULT_IMAGE_PLACEHOLDER_HASH = "4a48f26592f4e1498d7a478a4c48609c";
const MD5_MODULE_ID = "8494e7d7-6b99-47b2-a741-59873e42f16f";
const PROFILE_IMAGE_URL = "/_layouts/15/userphoto.aspx?size=M&accountname=";
/**
* Gets user photo
* @param userId
* @returns user photo
*/
export const getUserPhoto = async (userId: string): Promise<string> => {
const personaImgUrl = PROFILE_IMAGE_URL + userId;
// tslint:disable-next-line: no-use-before-declare
const url: string = await getImageBase64(personaImgUrl);
const newHash = await getMd5HashForUrl(url);
if (
newHash !== DEFAULT_PERSONA_IMG_HASH &&
newHash !== DEFAULT_IMAGE_PLACEHOLDER_HASH
) {
return "data:image/png;base64," + url;
} else {
return "undefined";
}
};
/**
* Get MD5Hash for the image url to verify whether user has default image or custom image
* @param url
*/
export const getMd5HashForUrl = async (url: string): Promise<string> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const library : any = await loadSPComponentById(MD5_MODULE_ID) ;
try {
const md5Hash = library.Md5Hash;
if (md5Hash) {
const convertedHash = md5Hash(url);
return convertedHash;
}
} catch (error) {
return url;
}
};
/**
* Load SPFx component by id, SPComponentLoader is used to load the SPFx components
* @param componentId - componentId, guid of the component library
*/
export const loadSPComponentById = async (
componentId: string
): Promise<unknown> => {
const component: unknown = SPComponentLoader.loadComponentById(componentId)
return component;
};
/**
* Gets image base64
* @param pictureUrl
* @returns image base64
*/
export const getImageBase64 = async (pictureUrl: string): Promise<string> => {
console.log(pictureUrl);
return new Promise((resolve) => {
const image = new Image();
image.addEventListener("load", () => {
const tempCanvas = document.createElement("canvas");
(tempCanvas.width = image.width),
(tempCanvas.height = image.height),
tempCanvas.getContext("2d").drawImage(image, 0, 0);
let base64Str;
try {
base64Str = tempCanvas.toDataURL("image/png");
} catch (e) {
return "";
}
base64Str = base64Str.replace(/^data:image\/png;base64,/, "");
resolve(base64Str);
});
image.src = pictureUrl;
});
};

View File

@ -0,0 +1,89 @@
import {
Stack,
DocumentCard,
DocumentCardDetails,
PersonaSize,
DocumentCardActions,
IButtonProps,
} from "office-ui-fabric-react";
import * as React from "react";
import { Person } from "../Person/Person";
import { ICompactCardProps } from "./ICompactCardProps";
import { useHoverCardStyles } from "./useHoverCardStyles";
export const CompactCard: React.FunctionComponent<ICompactCardProps> = (
props: ICompactCardProps
) => {
const { user } = props;
const {
stackPersonaStyles,
hoverCardStyles,
buttonStylesHouver,
documentCardActionStyles
} = useHoverCardStyles();
const documentCardActionsHouver: IButtonProps[] = React.useMemo(() =>{
const actions:IButtonProps[] = [] ;
actions.push(
{
iconProps: { iconName: "Chat" },
title: "Chat",
styles: buttonStylesHouver,
onClick: (ev)=>{
ev.preventDefault();
ev.stopPropagation();
window.open(`https://teams.microsoft.com/l/chat/0/0?users=${user.email}&message=Hi ${user.displayName} `,"_blank");
}
}
);
if (user?.workPhone){
actions.push(
{
iconProps: { iconName: "Phone" },
title: "Call",
styles: buttonStylesHouver,
onClick: (ev)=> {
ev.preventDefault();
ev.stopPropagation();
window.open(`CALLTO:${user.workPhone}`,"_blank");
}
}
)
}
return actions;
},[buttonStylesHouver, user.displayName, user.email, user.workPhone]);
return (
<>
<Stack
tokens={{ childrenGap: 10 }}
horizontalAlign="start"
verticalAlign="center"
>
<DocumentCard className={hoverCardStyles.hoverHeader}>
<DocumentCardDetails>
<Stack
horizontal
horizontalAlign="space-between"
styles={stackPersonaStyles}
>
<Person
text={user.displayName}
secondaryText={user.title}
tertiaryText={user.department}
userEmail={user.email}
pictureUrl={user.pictureUrl}
size={PersonaSize.size72}
/>
</Stack>
<DocumentCardActions actions={documentCardActionsHouver} styles={documentCardActionStyles} />
</DocumentCardDetails>
</DocumentCard>
</Stack>
</>
);
};

View File

@ -0,0 +1,140 @@
import {
Stack,
FontIcon,
Text,
IStackTokens,
PersonaSize,
Link,
} from "office-ui-fabric-react"
import * as React from "react";
import { Person } from "../Person/Person";
import { IExpandedCardProps } from "./IExpandedCardProps";
import { useHoverCardStyles } from "./useHoverCardStyles";
import {
useGetUserProperties,
manpingUserProperties,
} from "../../hooks/useGetUserProperties";
import { IUserInfo } from "../../models/IUserInfo";
export const ExpandedCard: React.FunctionComponent<IExpandedCardProps> = (
props: IExpandedCardProps
) => {
const { user } = props;
const { expandedCardStackStyle, hoverCardStyles } = useHoverCardStyles();
const stackFieldTokens: IStackTokens = {
childrenGap: 15,
};
const { getUserProfile } = useGetUserProperties();
const [manager, setManager] = React.useState<IUserInfo>();
React.useEffect(() => {
if (!user.manager) {
return;
}
(async () => {
const { currentUserProfile } = await getUserProfile(user.manager);
const wManager: IUserInfo = await manpingUserProperties(
currentUserProfile
);
setManager(wManager);
})();
}, [getUserProfile, user.manager]);
return (
<>
<Stack tokens={{ childrenGap: 10 }} styles={expandedCardStackStyle}>
<Text variant="medium" style={{ fontWeight: 600 }}>
Contact
</Text>
{
user.email && (
<>
<Stack
horizontal
horizontalAlign="start"
verticalAlign="center"
styles={{root:{padding: 5}}}
tokens={stackFieldTokens}
>
<FontIcon iconName="mail" className={hoverCardStyles.iconStyles} />
<Link href={`MAILTO:${user.email}`} target="_blank"
data-interception="off">
<Text variant="smallPlus">{user.email}</Text>
</Link>
</Stack>
<div className={hoverCardStyles.separatorHorizontal}></div>
</>
)
}
{user.workPhone && (
<>
<Stack
horizontal
horizontalAlign="start"
verticalAlign="center"
tokens={stackFieldTokens}
styles={{root:{padding: 5}}}
>
<FontIcon
iconName="Phone"
className={hoverCardStyles.iconStyles}
/>
<Link href={`CALLTO:${user.workPhone}`} target="_blank"
data-interception="off">
<Text variant="smallPlus">{user.workPhone}</Text>
</Link>
</Stack>
<div className={hoverCardStyles.separatorHorizontal}></div>
</>
)}
{user.location && (
<>
<Stack
horizontal
horizontalAlign="start"
verticalAlign="center"
tokens={stackFieldTokens}
styles={{root:{padding: 5}}}
>
<FontIcon
iconName="MapPin"
className={hoverCardStyles.iconStyles}
/>
<Link
href={`https://www.bing.com/maps?q=${encodeURIComponent(
user.location
)}`}
target="_blank"
data-interception="off"
>
<Text variant="smallPlus">{user.location}</Text>
</Link>
</Stack>
</>
)}
{manager && (
<>
<Text
variant="medium"
style={{ fontWeight: 600, marginTop: 25, marginBottom: 10 }}
>
Reports to
</Text>
<Person
userEmail={manager.email}
size={PersonaSize.size48}
pictureUrl={manager.pictureUrl}
text={manager.displayName}
secondaryText={manager.title}
></Person>
</>
)}
</Stack>
</>
);
};

View File

@ -0,0 +1,6 @@
import { IUserInfo } from "../../models/IUserInfo";
export interface ICompactCardProps {
user: IUserInfo
}

View File

@ -0,0 +1,6 @@
import { IUserInfo } from "../../models/IUserInfo";
export interface IExpandedCardProps {
user: IUserInfo
}

View File

@ -0,0 +1,5 @@
export * from './CompactCard';
export * from './ExpandedCard';
export * from './ICompactCardProps';
export * from './IExpandedCardProps';
export * from './useHoverCardStyles';

View File

@ -0,0 +1,73 @@
import { IButtonStyles, IDocumentCardActionsStyles, IStackStyles, mergeStyles, mergeStyleSets } from "office-ui-fabric-react";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import Theme from "spfx-uifabric-themes";
const currentTheme = window.__themeState__.theme;
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useHoverCardStyles = () => {
const stackPersonaStyles: Partial<IStackStyles> = {
root: { padding: 15 },
};
const stackCompactStyles: Partial<IStackStyles> = {
root: { padding: 15 },
};
const documentCardActionStyles: Partial<IDocumentCardActionsStyles> = {
root: {
width: '100%',
marginTop:10,
paddingLeft: 10,
paddingRight: 10,
backgroundColor: currentTheme.neutralLighter,
borderTopWidth: 1,
borderTopStyle: "solid",
borderTopColor: currentTheme.neutralLight,
},
};
const buttonStylesHouver: IButtonStyles = {
root: {
marginRight: 10,
},
icon: {
fontSize: 16,
},
iconHovered: {
// color: currentTheme.themePrimary,
fontWeight: 600,
},
};
const expandedCardStackStyle: IStackStyles = {
root: {
// marginTop: 10,
paddingLeft: 25,
paddingRight: 25,
paddingTop: 15,
paddingBottom: 30,
backgroundColor: currentTheme.neutralLighterAlt,
},
};
const hoverCardStyles = mergeStyleSets({
separatorHorizontal: mergeStyles({
width: "100%",
height:0,
borderTopWidth: 1,
borderTopStyle: "solid",
borderTopColor: currentTheme.neutralLight,
}),
iconStyles: mergeStyles({
fontSize: 16, color: currentTheme.themePrimary
}),
hoverHeader: mergeStyles({
minWidth: '100%',
borderStyle: "none",
borderWidth: 0,
borderRadius: 0,
}),
});
return {stackCompactStyles, documentCardActionStyles, hoverCardStyles, expandedCardStackStyle, buttonStylesHouver, stackPersonaStyles };
};

View File

@ -0,0 +1,8 @@
export enum EOrgChartTypes {
'SET_RENDER_MANAGERS' = 'SET_RENDER_MANAGERS',
'SET_RENDER_DIRECT_REPORTS' = 'SET_RENDER_DIRECT_REPORTS',
'SET_IS_LOADING' = 'SET_IS_LOADING',
'SET_HAS_ERROR' = 'SET_HAS_ERROR',
'SET_CURRENT_USER' = 'SET_CURRENT_USER',
'SET_START_FROM_USER' = 'SET_START_FROM_USER'
}

View File

@ -0,0 +1,12 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { IPropertyFieldGroupOrPerson } from '@pnp/spfx-property-controls/lib/PropertyFieldPeoplePicker';
export interface IOrgChartProps {
title: string;
defaultUser: string;
context: WebPartContext;
startFromUser: IPropertyFieldGroupOrPerson[];
showAllManagers: boolean;
showActionsBar:boolean;
}

View File

@ -0,0 +1,12 @@
import { IUserInfo } from "../../models/IUserInfo";
export interface IErrorInfo {
hasError: boolean;
errorMessage: string;
}
export interface IOrgChartState {
isLoading: boolean;
error:IErrorInfo;
renderManagers:JSX.Element[];
renderDirectReports: JSX.Element[];
currentUser:IUserInfo;
}

View File

@ -0,0 +1,83 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.OrgChart {
.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);
}
.treeContainer{
height: 1080px;
overflow: auto;
}
.rst__rowContents {
border-radius: 10px;
box-shadow: 2px -11px 46px -10px rgba(0,0,0,0.1);
}
.row {
@include ms-Grid-row;
@include ms-fontColor-white;
background-color: $ms-color-themeDark;
padding: 20px;
}
.column {
@include ms-Grid-col;
@include ms-lg10;
@include ms-xl8;
@include ms-xlPush2;
@include ms-lgPush1;
}
.title {
@include ms-font-xl;
@include ms-fontColor-white;
}
.subTitle {
@include ms-font-l;
@include ms-fontColor-white;
}
.description {
@include ms-font-l;
@include ms-fontColor-white;
}
.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;
}
}
}

View File

@ -0,0 +1,306 @@
import * as React from "react";
import { IOrgChartProps } from "./IOrgChartProps";
import { IOrgChartState } from "./IOrgChartState";
import { OrgChartReducer } from "./OrgChartReducer";
import {
useGetUserProperties,
manpingUserProperties,
} from "../../hooks/useGetUserProperties";
import { IStackStyles, Stack } from "office-ui-fabric-react/lib/Stack";
import { PersonCard } from "../PersonCard/PersonCard";
import { IUserInfo } from "../../models/IUserInfo";
import { EOrgChartTypes } from "./EOrgChartTypes";
import {
MessageBar,
MessageBarType,
Overlay,
Spinner,
SpinnerSize,
Text,
} from "office-ui-fabric-react";
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
import { getGUID } from "@pnp/common";
import { useOrgChartStyles } from "./useOrgChartStyles";
const initialState: IOrgChartState = {
isLoading: true,
renderDirectReports: [],
renderManagers: [],
error: undefined,
currentUser: undefined,
};
const titleStyle: IStackStyles = {
root: {
paddingBottom: 40,
},
};
export const OrgChart: React.FunctionComponent<IOrgChartProps> = (
props: IOrgChartProps
) => {
const { getUserProfile } = useGetUserProperties();
const [state, dispatch] = React.useReducer(OrgChartReducer, initialState);
const { orgChartClasses } = useOrgChartStyles();
const {
renderManagers,
renderDirectReports,
currentUser,
isLoading,
error,
}: IOrgChartState = state;
const {
context,
showAllManagers,
startFromUser,
showActionsBar,
title,
}: IOrgChartProps = props;
const startFromUserId: string = React.useMemo(
() => startFromUser && startFromUser[0].id,
[startFromUser]
);
const onUserSelected = React.useCallback((selectedUser: IUserInfo) => {
dispatch({
type: EOrgChartTypes.SET_CURRENT_USER,
payload: selectedUser,
});
}, []);
const loadOrgChart = React.useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async (selectedUser: string): Promise<any> => {
const wRenderManagers: JSX.Element[] = [];
const wRenderDirectReports: JSX.Element[] = [];
try {
const { managersList, reportsLists } = await getUserProfile(
selectedUser,
startFromUserId,
showAllManagers
);
if (managersList) {
for (const managerInfo of managersList) {
wRenderManagers.push(
<>
<PersonCard
key={getGUID()}
userInfo={managerInfo}
onUserSelected={onUserSelected}
selectedUser={currentUser}
showActionsBar={showActionsBar}
></PersonCard>
<div
key={getGUID()}
className={orgChartClasses.separatorVertical}
></div>
</>
);
}
for (const directReport of reportsLists) {
wRenderDirectReports.push(
<>
<PersonCard
key={getGUID()}
userInfo={directReport}
onUserSelected={onUserSelected}
selectedUser={currentUser}
showActionsBar={showActionsBar}
></PersonCard>
</>
);
}
}
dispatch({
type: EOrgChartTypes.SET_HAS_ERROR,
payload: { hasError: false, errorMessage: "" },
});
} catch (error) {
console.log(error);
dispatch({
type: EOrgChartTypes.SET_IS_LOADING,
payload: false,
});
dispatch({
type: EOrgChartTypes.SET_HAS_ERROR,
payload: {
hasError: true,
errorMessage: "error",
},
});
}
return { wRenderDirectReports, wRenderManagers };
},
[
getUserProfile,
startFromUserId,
showAllManagers,
onUserSelected,
currentUser,
showActionsBar,
orgChartClasses.separatorVertical,
]
);
React.useEffect(() => {
(async () => {
try {
if (startFromUserId === undefined) return;
if (startFromUserId === ''){
dispatch({
type: EOrgChartTypes.SET_IS_LOADING,
payload: false,
});
dispatch({
type: EOrgChartTypes.SET_HAS_ERROR,
payload: {
hasError: true,
errorMessage: "User don't have email defined",
},
});
return;
}
const { currentUserProfile } = await getUserProfile(startFromUserId);
const wCurrentUser: IUserInfo = await manpingUserProperties(
currentUserProfile
);
dispatch({
type: EOrgChartTypes.SET_CURRENT_USER,
payload: wCurrentUser,
});
dispatch({
type: EOrgChartTypes.SET_HAS_ERROR,
payload: { hasError: false, errorMessage: "" },
});
} catch (error) {
console.log(error);
dispatch({
type: EOrgChartTypes.SET_IS_LOADING,
payload: false,
});
dispatch({
type: EOrgChartTypes.SET_HAS_ERROR,
payload: {
hasError: true,
errorMessage: "error",
},
});
}
})();
}, [getUserProfile, startFromUserId]);
React.useEffect(() => {
(async () => {
if (!currentUser) return;
dispatch({
type: EOrgChartTypes.SET_IS_LOADING,
payload: true,
});
const { wRenderDirectReports, wRenderManagers } = await loadOrgChart(
currentUser.id
);
dispatch({
type: EOrgChartTypes.SET_RENDER_MANAGERS,
payload: wRenderManagers,
});
dispatch({
type: EOrgChartTypes.SET_RENDER_DIRECT_REPORTS,
payload: wRenderDirectReports,
});
dispatch({
type: EOrgChartTypes.SET_IS_LOADING,
payload: false,
});
})();
}, [currentUser, loadOrgChart]);
if (!startFromUser) {
return (
<Placeholder
iconName="Edit"
iconText="Configure your Organization Chart Web Part"
description={"Please configure web part"}
buttonLabel="Configure"
onConfigure={context.propertyPane.open}
/>
);
}
if (isLoading) {
return (
<Overlay style={{ height: "100%", position: "fixed" }}>
<Stack style={{ height: "100%" }} verticalAlign="center">
<Spinner
styles={{ root: { zIndex: 9999 } }}
size={SpinnerSize.large}
label={"loading Organization Chart..."}
labelPosition={"bottom"}
></Spinner>
</Stack>
</Overlay>
);
}
if (error && error.hasError) {
return (
<Stack
horizontal
horizontalAlign="center"
styles={{root:{padding: 20}}}
tokens={{ childrenGap: 10 }}
>
<MessageBar messageBarType={MessageBarType.error} isMultiline>
{error.errorMessage}
</MessageBar>
</Stack>
);
}
return (
<>
<Stack styles={{root:{padding: 20}}} >
<Stack horizontal horizontalAlign="center" styles={titleStyle}>
<Text variant="xLarge" block>
{title}
</Text>
</Stack>
<Stack horizontalAlign="center" verticalAlign="center">
{renderManagers}
<PersonCard
key={getGUID()}
userInfo={currentUser}
onUserSelected={onUserSelected}
selectedUser={currentUser}
showActionsBar={showActionsBar}
></PersonCard>
{renderDirectReports.length && (
<>
<div className={orgChartClasses.separatorVertical}></div>
<div className={orgChartClasses.separatorHorizontal}></div>
</>
)}
</Stack>
<Stack
horizontal
horizontalAlign="center"
styles={{root:{padding: 10}}}
tokens={{ childrenGap: 15 }}
wrap
>
{renderDirectReports}
</Stack>
</Stack>
</>
);
};

View File

@ -0,0 +1,23 @@
import { IErrorInfo } from "../../components/OrgChart/IOrgChartState";
import { IUserInfo } from "../../models/IUserInfo";
import { EOrgChartTypes } from "./EOrgChartTypes";
import { IOrgChartState } from "./IOrgChartState";
export const OrgChartReducer = (
state: IOrgChartState,
action: { type: EOrgChartTypes; payload: unknown }
):IOrgChartState => {
switch (action.type) {
case EOrgChartTypes.SET_RENDER_MANAGERS:
return { ...state, renderManagers: action.payload as JSX.Element[] };
case EOrgChartTypes.SET_RENDER_DIRECT_REPORTS:
return { ...state, renderDirectReports: action.payload as JSX.Element[]};
case EOrgChartTypes.SET_IS_LOADING:
return { ...state, isLoading: action.payload as boolean};
case EOrgChartTypes.SET_HAS_ERROR:
return { ...state, error: action.payload as IErrorInfo};
case EOrgChartTypes.SET_CURRENT_USER:
return { ...state, currentUser: action.payload as IUserInfo};
default:
return state;
}
};

View File

@ -0,0 +1,6 @@
export * from './EOrgChartTypes';
export * from './IOrgChartProps';
export * from './IOrgChartState';
export * from './OrgChart.module.scss';
export * from './OrgChart';
export * from './OrgChartReducer';

View File

@ -0,0 +1,33 @@
import { mergeStyles, mergeStyleSets } from "office-ui-fabric-react";
const currentTheme = window.__themeState__.theme;
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useOrgChartStyles = () => {
const orgChartClasses = mergeStyleSets({
tilesContainer: mergeStyles({
marginBottom: 10,
marginTop: 0,
gridGap: "10px",
padding: 10,
justifyContent: "center",
}),
separatorVertical: mergeStyles({
height: 25,
borderStyle: "solid",
borderWidth: 1,
borderColor: currentTheme.neutralQuaternary,
}),
separatorHorizontal: mergeStyles({
width: "100%",
borderStyle: "solid",
borderWidth: 1,
borderColor: currentTheme.neutralQuaternary,
}),
});
return { orgChartClasses };
};

View File

@ -0,0 +1,9 @@
import { PersonaSize } from "office-ui-fabric-react/lib/Persona";
export interface IPersonProps {
userEmail: string;
text: string;
secondaryText: string;
tertiaryText?: string;
pictureUrl?:string;
size?: PersonaSize;
}

View File

@ -0,0 +1,72 @@
import * as React from "react";
import {
IPersonaSharedProps,
Persona,
PersonaSize,
} from "office-ui-fabric-react/lib/Persona";
import { Text } from "office-ui-fabric-react/lib/Text";
import { IPersonProps } from "./IPersonProps";
export const Person: React.FunctionComponent<IPersonProps> = (
props: IPersonProps
) => {
const { text, secondaryText, userEmail, size, tertiaryText , pictureUrl} = props;
const personProps: IPersonaSharedProps = React.useMemo(() => {
return {
imageUrl: pictureUrl ? `/_layouts/15/userphoto.aspx?size=M&accountname=${userEmail}` : undefined,
text: text,
secondaryText: secondaryText,
tertiaryText: tertiaryText,
};
}, [pictureUrl, userEmail, text, secondaryText, tertiaryText]);
const _onRenderPrimaryText = React.useCallback(() => {
return (
<>
<Text
title={text}
variant="mediumPlus"
block
nowrap
styles={{ root: { fontWeight: 600 } }}
>
{text}
</Text>
</>
);
}, [text]);
const _onRenderSecondaryText = React.useCallback(() => {
return (
<>
<Text
title={secondaryText}
variant="smallPlus"
block
nowrap
styles={{ root: { fontWeight: 400 } }}
>
{secondaryText}
</Text>
</>
);
}, [secondaryText]);
return (
<>
<Persona
{...personProps}
size={size || PersonaSize.size40}
onRenderPrimaryText={_onRenderPrimaryText}
onRenderSecondaryText={_onRenderSecondaryText}
styles={{
secondaryText: { maxWidth: 230 },
primaryText: { maxWidth: 230 },
}}
/>
</>
);
};

View File

@ -0,0 +1,8 @@
import { IUserInfo } from "../../models";
export interface IPersonCardProps {
userInfo: IUserInfo;
onUserSelected: (user: IUserInfo) => void;
selectedUser?: IUserInfo;
showActionsBar?: boolean;
}

View File

@ -0,0 +1,167 @@
/* tslint:disable */
import * as React from "react";
import {
DocumentCard,
DocumentCardActions,
DocumentCardDetails,
IDocumentCard,
} from "office-ui-fabric-react/lib/DocumentCard";
import {
Stack,
IButtonProps,
HoverCard,
HoverCardType,
IExpandingCardProps,
PersonaSize,
DirectionalHint,
} from "office-ui-fabric-react";
import { Person } from "../Person/Person";
import { IUserInfo } from "../../models/IUserInfo";
import { ExpandedCard, CompactCard } from "../HoverCard";
import { usePersonaCardStyles } from "./usePersonaCardStyles";
import { IPersonCardProps } from "./IPersonCardProps";
const currentTheme = window.__themeState__.theme;
export const PersonCard: React.FunctionComponent<IPersonCardProps> = (
props: IPersonCardProps
) => {
const { userInfo, onUserSelected, showActionsBar } = props;
const documentCardRef = React.useRef<IDocumentCard>(undefined);
const {
personaCardStyles,
documentCardActionStyles,
buttonStyles,
stackPersonaStyles,
} = usePersonaCardStyles();
const documentCardActions: IButtonProps[] = React.useMemo(() => {
const cardActions: IButtonProps[] = [];
cardActions.push({
iconProps: { iconName: "Chat" },
title: "Chat",
styles: buttonStyles,
onClick: (ev) => {
ev.preventDefault();
ev.stopPropagation();
window.open(
`https://teams.microsoft.com/l/chat/0/0?users=${userInfo.email}&message=Hi ${userInfo.displayName} `,
"_blank"
);
},
});
if (userInfo?.email) {
cardActions.push({
iconProps: { iconName: "Mail" },
title: "Mail",
styles: buttonStyles,
onClick: (ev) => {
ev.preventDefault();
ev.stopPropagation();
window.open(`MAILTO:${userInfo.email}`, "_blank");
},
});
}
if (userInfo?.workPhone) {
cardActions.push({
iconProps: { iconName: "Phone" },
title: "Call",
styles: buttonStyles,
onClick: (ev) => {
ev.preventDefault();
ev.stopPropagation();
window.open(`CALLTO:${userInfo.workPhone}`, "_blank");
},
});
}
if (userInfo.hasDirectReports) {
cardActions.push({
iconProps: { iconName: "Org" },
title: "View Organization",
styles: { ...buttonStyles },
});
}
return cardActions;
}, [
buttonStyles,
userInfo.displayName,
userInfo.email,
userInfo.hasDirectReports,
userInfo.workPhone,
]);
const onRenderCompactCard = React.useCallback(
(user: IUserInfo): JSX.Element => <CompactCard user={user} />,
[]
);
const onRenderExpandedCard = React.useCallback(
(user: IUserInfo): JSX.Element => <ExpandedCard user={user} />,
[]
);
const expandingCardProps: IExpandingCardProps = React.useMemo(() => {
return {
onRenderCompactCard: onRenderCompactCard,
onRenderExpandedCard: onRenderExpandedCard,
renderData: userInfo,
directionalHint: DirectionalHint.rightTopEdge,
styles: {
expandedCard: { backgroundColor: currentTheme.neutralLighterAlt },
},
gapSpace: 5,
calloutProps: {
isBeakVisible: false,
},
};
}, [onRenderCompactCard, onRenderExpandedCard, userInfo]);
return (
<>
<DocumentCard
className={personaCardStyles.tile}
componentRef={documentCardRef}
onClick={() => {
// documentCardRef.current.focus();
if (userInfo.hasDirectReports) {
onUserSelected(userInfo);
//
}
}}
>
<HoverCard
expandingCardProps={expandingCardProps}
type={HoverCardType.expanding}
>
<DocumentCardDetails>
<Stack
horizontal
horizontalAlign="space-between"
styles={stackPersonaStyles}
>
<Person
text={userInfo.displayName}
secondaryText={userInfo.title}
userEmail={userInfo.email}
pictureUrl={userInfo.pictureUrl}
size={PersonaSize.size40}
/>
</Stack>
</DocumentCardDetails>
</HoverCard>
{showActionsBar && (
<DocumentCardActions
actions={documentCardActions}
styles={documentCardActionStyles}
/>
)}
</DocumentCard>
</>
);
};

View File

@ -0,0 +1,2 @@
export * from './PersonCard';
export * from './usePersonaCardStyles';

View File

@ -0,0 +1,101 @@
import { IButtonStyles, IDocumentCardActionsStyles, IStackStyles, mergeStyles, mergeStyleSets } from "office-ui-fabric-react";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import Theme from "spfx-uifabric-themes";
const currentTheme = window.__themeState__.theme;
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const usePersonaCardStyles = () => {
const stackPersonaStyles: Partial<IStackStyles> = {
root: { padding: 15 },
};
const buttonStyles: IButtonStyles = {
icon: {
fontSize: 12,
},
iconHovered: {
fontWeight: 600,
},
};
const documentCardActionStyles: Partial<IDocumentCardActionsStyles> = {
root: {
height: 34,
padding: 0,
backgroundColor: currentTheme.neutralLighterAlt,
borderTopWidth: 1,
borderTopStyle: "solid",
borderTopColor: currentTheme.neutralLight,
width: "100%",
},
};
const personaCardStyles = mergeStyleSets({
separatorHorizontal: mergeStyles({
width: "100%",
borderWidth: 0.5,
borderStyle: "solid",
borderColor: currentTheme.neutralLight,
}),
iconStyles: mergeStyles({
fontSize: 16, color: currentTheme.themePrimary
}),
hoverHeader: mergeStyles({
minWidth: 260,
maxWidth: 260,
borderStyle: "none",
borderWidth: 0,
borderRadius: 0,
}),
tileCurrentUser: mergeStyles({
minWidth: 260,
maxWidth: '260px !important',
borderStyle: "solid",
borderWidth: 1,
borderRadius: 0,
borderColor: currentTheme.themePrimary,
boxShadow: "0 5px 15px rgba(50, 50, 90, .1)",
}),
tile: mergeStyles({
minWidth: 260,
maxWidth: '260px !important',
borderStyle: "solid",
borderWidth: 1,
borderRadius: 0,
borderColor: currentTheme.neutralQuaternaryAlt,
backgroundColor: currentTheme.white,
boxShadow: "0 5px 15px rgba(50, 50, 90, .1)",
selectors: {
":hover": {
borderStyle: "solid",
borderWidth: 1,
borderLeftStyle: "solid",
borderRadius: 0,
borderColor: currentTheme.themePrimary,
// borderColor: props.color,
// borderTopWidth: 2,
},
":focus": {
borderStyle: "solid",
borderWidth: 1,
borderLeftStyle: "solid",
borderRadius: 0,
borderColor: currentTheme.themePrimary,
// borderTopWidth: 2,
},
"@media(max-width : 480px)": {
maxWidth: "100%",
minWidth: "100%",
},
"@media((min-width : 481px) and (max-width : 12480px))": {
maxWidth: 260,
minWidth: "50%",
},
}
}),
});
return {documentCardActionStyles, personaCardStyles, buttonStyles, stackPersonaStyles };
};

View File

@ -0,0 +1 @@
export * from './useGetUserProperties';

View File

@ -0,0 +1,144 @@
import { sp, SPBatch} from "@pnp/sp/";
import { IUserInfo } from "../models/IUserInfo";
import * as React from "react";
import { get, set } from "idb-keyval";
import { sortBy, filter } from "lodash";
import { IPersonProperties } from "../models/IPersonProperties";
/*************************************************************************************/
// Hook to get user profile information
// *************************************************************************************/
type getUserProfileFunc = ( currentUser: string,
startUser?: string,
showAllManagers?: boolean) => Promise<returnProfileData>;
type returnProfileData = { managersList:IUserInfo[], reportsLists:IUserInfo[], currentUserProfile :IPersonProperties} ;
export const useGetUserProperties = (): { getUserProfile:getUserProfileFunc } => {
const getUserProfile = React.useCallback(
async (
currentUser: string,
startUser?: string,
showAllManagers?: boolean
): Promise<returnProfileData> => {
if (!currentUser) return;
const loginName = currentUser;
const loginNameStartUser: string = startUser && startUser;
const cacheCurrentUser:IPersonProperties = await get(`${loginName}__orgchart__`);
let currentUserProfile:IPersonProperties = undefined;
if (!cacheCurrentUser) {
currentUserProfile = await sp.profiles.getPropertiesFor(loginName);
// console.log(currentUserProfile);
await set(`${loginName}__orgchart__`, currentUserProfile);
} else {
currentUserProfile = cacheCurrentUser;
}
// get Managers and Direct Reports
let reportsLists: IUserInfo[] = [];
let managersList: IUserInfo[] = [];
const wDirectReports: string[] =
currentUserProfile && currentUserProfile.DirectReports;
const wExtendedManagers: string[] =
currentUserProfile && currentUserProfile.ExtendedManagers;
// Get Direct Reports if exists
if (wDirectReports && wDirectReports.length > 0) {
reportsLists = await getDirectReports(wDirectReports);
}
// Get Managers if exists
if (startUser && wExtendedManagers && wExtendedManagers.length > 0) {
managersList = await getExtendedManagers(
wExtendedManagers,
loginNameStartUser,
showAllManagers
);
}
return { managersList, reportsLists, currentUserProfile } ;
},
[]
);
return { getUserProfile } ;
};
const getDirectReports = async (
directReports: string[]
): Promise<IUserInfo[]> => {
const _reportsList: IUserInfo[] = [];
const batch: SPBatch = sp.createBatch();
for (const userReport of directReports) {
const cacheDirectReport: IPersonProperties = await get(`${userReport}__orgchart__`);
if (!cacheDirectReport) {
sp.profiles
.inBatch(batch)
.getPropertiesFor(userReport)
.then(async (directReport: IPersonProperties) => {
_reportsList.push(await manpingUserProperties(directReport));
await set(`${userReport}__orgchart__`, directReport);
});
} else {
_reportsList.push(await manpingUserProperties(cacheDirectReport));
}
}
await batch.execute();
return sortBy(_reportsList, ["displayName"]);
};
const getExtendedManagers = async (
extendedManagers: string[],
startUser: string,
showAllManagers: boolean
): Promise<IUserInfo[]> => {
const wManagers: IUserInfo[] = [];
const batch: SPBatch = sp.createBatch();
for (const manager of extendedManagers) {
if (!showAllManagers && manager !== startUser) {
continue;
}
const cacheManager: IPersonProperties = await get(`${manager}__orgchart__`);
if (!cacheManager) {
sp.profiles
.inBatch(batch)
.getPropertiesFor(manager)
.then(async (_profile: IPersonProperties) => {
wManagers.push(await manpingUserProperties(_profile));
await set(`${manager}__orgchart__`, _profile);
});
} else {
wManagers.push(await manpingUserProperties(cacheManager));
}
}
await batch.execute();
return wManagers;
};
export const manpingUserProperties = async (
userProperties: IPersonProperties
): Promise<IUserInfo> => {
return {
displayName: userProperties.DisplayName as string,
email: userProperties.Email as string,
title: userProperties.Title as string,
pictureUrl: userProperties.PictureUrl,
id: userProperties.AccountName,
userUrl: userProperties.UserUrl,
numberDirectReports: userProperties.DirectReports.length,
hasDirectReports: userProperties.DirectReports.length > 0 ? true : false,
hasPeers: userProperties.Peers.length > 0 ? true : false,
numberPeers: userProperties.Peers.length,
department: filter(userProperties?.UserProfileProperties,{"Key": "Department"})[0].Value ?? '',
workPhone: filter(userProperties?.UserProfileProperties,{"Key": "WorkPhone"})[0].Value ?? '',
cellPhone: filter(userProperties?.UserProfileProperties,{"Key": "CellPhone"})[0].Value ?? '',
location: filter(userProperties?.UserProfileProperties,{"Key": "SPS-Location"})[0].Value ?? '',
office: filter(userProperties?.UserProfileProperties,{"Key": "Office"})[0].Value ?? '',
manager: filter(userProperties?.UserProfileProperties,{"Key": "Manager"})[0].Value ?? '',
loginName: userProperties.loginName
};
};

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,18 @@
export interface IPersonProperties {
AccountName: string;
DirectReports: string[];
DisplayName: string;
Email: string;
ExtendedManagers: string[];
ExtendedReports: string[];
IsFollowed: boolean;
LatestPost: string;
Peers: string[];
PersonalSiteHostUrl: string;
PersonalUrl: string;
PictureUrl: string;
Title: string;
UserProfileProperties: { Key: string; Value: string; ValueType: string }[];
UserUrl: string;
loginName: string;
}

View File

@ -0,0 +1,8 @@
export interface IUser {
displayName: string;
email: string;
isAnonymousGuestUser?: boolean;
isExternalGuestUser?: boolean;
loginName?: string;
preferUserTimeZone?: boolean;
}

View File

@ -0,0 +1,20 @@
/* tslint:disable */
import { IUser } from "./IUser";
export interface IUserInfo extends IUser {
title:string;
pictureUrl?: string;
userUrl?:string;
id?:string;
hasDirectReports?:boolean;
numberDirectReports?:number;
hasPeers?:boolean;
numberPeers?:number;
manager?:string;
department?:string;
workPhone?:string;
cellPhone?:string;
location?:string;
office?: string;
}

View File

@ -0,0 +1,3 @@
export * from './IPersonProperties';
export * from './IUser';
export * from './IUserInfo';

View File

@ -0,0 +1,29 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "0338da15-07f9-4a53-b267-d790fb495cca",
"alias": "OrganizationChartWebPart",
"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,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "SPFx - Custom WebParts" },
"title": { "default": "Organization Chart" },
"description": { "default": "Show Company Organization Chart" },
"officeFabricIconFontName": "Org",
"properties": {
"title": "Organization Chart",
"showAllManagers": true,
"showActionsBar": true
}
}]
}

View File

@ -0,0 +1,99 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneToggle
} from '@microsoft/sp-webpart-base';
import {
PropertyFieldPeoplePicker,
PrincipalType,
IPropertyFieldGroupOrPerson,
} from "@pnp/spfx-property-controls/lib/PropertyFieldPeoplePicker";
import * as strings from 'OrganizationChartWebPartStrings';
import {OrgChart} from '../../components/OrgChart/OrgChart';
import { IOrgChartProps } from "../../components/OrgChart/IOrgChartProps";
import { sp } from "@pnp/sp";
export interface IOrganizationChartWebPartProps {
title: string;
currentUser: string;
selectedUser: IPropertyFieldGroupOrPerson[];
showAllManagers: boolean;
showActionsBar: boolean;
}
export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrganizationChartWebPartProps> {
public async onInit(): Promise<void> {
sp.setup({
spfxContext: this.context,
});
return Promise.resolve();
}
public render(): void {
const element: React.ReactElement<IOrgChartProps > = React.createElement(
OrgChart,
{
title: this.properties.title,
defaultUser: this.properties.currentUser,
startFromUser: this.properties.selectedUser,
showAllManagers: this.properties.showAllManagers,
context: this.context,
showActionsBar: this.properties.showActionsBar
}
);
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("title", {
label: strings.TitleFieldLabel,
}),
PropertyFieldPeoplePicker("selectedUser", {
context: this.context,
label: strings.startFromUserLabel,
initialData: this.properties.selectedUser,
key: "peopleFieldId",
multiSelect: false,
allowDuplicate: false,
principalType: [PrincipalType.Users],
onPropertyChange: this.onPropertyPaneFieldChanged,
properties: this.properties,
onGetErrorMessage: null,
}),
PropertyPaneToggle("showAllManagers", {
label: strings.showAllManagers,
}),
PropertyPaneToggle("showActionsBar", {
label: strings.showactionsLabel,
}),
],
},
],
},
],
};
}
}

View File

@ -0,0 +1,10 @@
define([], function() {
return {
"PropertyPaneDescription": "Company Organization Chart",
"BasicGroupName": "Properties",
"TitleFieldLabel": "Title",
"startFromUserLabel": "Start from user",
"showactionsLabel": "Show actions bar",
"showAllManagers": "Show all managers"
}
});

View File

@ -0,0 +1,13 @@
declare interface IOrganizationChartWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
TitleFieldLabel: string;
startFromUserLabel: string;
showactionsLabel:string;
showAllManagers:string;
}
declare module 'OrganizationChartWebPartStrings' {
const strings: IOrganizationChartWebPartStrings;
export = strings;
}

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es6",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"webpack-env"
],
"lib": [
"es5",
"es6",
"dom",
"es2015.collection",
"es2015.promise"
]
}
}