From 18fee714b87177f3dc1884c514bc75d2e8e260e5 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Mon, 24 Aug 2026 13:48:32 +0200 Subject: [PATCH] feat: Initialize organization chart web part with core functionality and configuration - Added configuration files: config.json, copy-assets.json, package-solution.json, serve.json, write-manifests.json. - Created gulpfile.js for build process. - Implemented core logic in OrganizationChartCore.ts for account normalization and profile handling. - Developed data service in OrganizationChartDataService.ts to fetch organization data from SharePoint User Profile Service. - Defined types for user profiles and organization nodes in OrganizationChartTypes.ts. - Created web part manifest for OrganizationChartWebPart. - Built the main web part component with rendering logic and property pane configuration in OrganizationChartWebPart.ts. - Developed React components for displaying the organization chart. - Implemented styling for the organization chart in OrganizationChart.module.scss. - Added localization support with English and German strings. - Created unit tests for core functionalities in OrganizationChartCore.test.ts. - Set up TypeScript and TSLint configurations for the project. --- .gitignore | 18 ++ .yo-rc.json | 11 ++ README.md | 51 ++++++ config/config.json | 16 ++ config/copy-assets.json | 1 + config/package-solution.json | 12 ++ config/serve.json | 6 + config/write-manifests.json | 1 + gulpfile.js | 3 + package.json | 33 ++++ src/index.ts | 1 + .../OrganizationChartCore.ts | 50 ++++++ .../OrganizationChartDataService.ts | 162 ++++++++++++++++++ .../OrganizationChartTypes.ts | 32 ++++ .../OrganizationChartWebPart.manifest.json | 28 +++ .../OrganizationChartWebPart.ts | 121 +++++++++++++ .../components/IOrganizationChartProps.ts | 13 ++ .../components/OrganizationChart.module.scss | 62 +++++++ .../components/OrganizationChart.tsx | 103 +++++++++++ src/webparts/organizationChart/loc/de-de.js | 26 +++ src/webparts/organizationChart/loc/en-us.js | 26 +++ .../organizationChart/loc/myStrings.d.ts | 28 +++ tests/OrganizationChartCore.test.ts | 30 ++++ tests/tsconfig.json | 17 ++ tests/validate-project.js | 41 +++++ tsconfig.json | 16 ++ tslint.json | 8 + 27 files changed, 916 insertions(+) create mode 100644 .gitignore create mode 100644 .yo-rc.json create mode 100644 README.md create mode 100644 config/config.json create mode 100644 config/copy-assets.json create mode 100644 config/package-solution.json create mode 100644 config/serve.json create mode 100644 config/write-manifests.json create mode 100644 gulpfile.js create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/webparts/organizationChart/OrganizationChartCore.ts create mode 100644 src/webparts/organizationChart/OrganizationChartDataService.ts create mode 100644 src/webparts/organizationChart/OrganizationChartTypes.ts create mode 100644 src/webparts/organizationChart/OrganizationChartWebPart.manifest.json create mode 100644 src/webparts/organizationChart/OrganizationChartWebPart.ts create mode 100644 src/webparts/organizationChart/components/IOrganizationChartProps.ts create mode 100644 src/webparts/organizationChart/components/OrganizationChart.module.scss create mode 100644 src/webparts/organizationChart/components/OrganizationChart.tsx create mode 100644 src/webparts/organizationChart/loc/de-de.js create mode 100644 src/webparts/organizationChart/loc/en-us.js create mode 100644 src/webparts/organizationChart/loc/myStrings.d.ts create mode 100644 tests/OrganizationChartCore.test.ts create mode 100644 tests/tsconfig.json create mode 100644 tests/validate-project.js create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a69a71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +lib/ +dist/ +temp/ +release/ +solution/ +sharepoint/solution/ +*.sppkg +*.tgz +coverage/ +*.resx.ts +*.scss.ts +*.log +npm-debug.log* +.vs/ +.idea/ +.DS_Store +Thumbs.db diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..3ddfe0b --- /dev/null +++ b/.yo-rc.json @@ -0,0 +1,11 @@ +{ + "@microsoft/generator-sharepoint": { + "version": "1.4.1", + "libraryName": "organization-chart-webpart", + "libraryId": "b2f88ea8-f7da-4d0e-a945-6973bf1acd1e", + "environment": "onprem", + "packageManager": "npm", + "solutionName": "organization-chart-webpart", + "componentType": "webpart" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a531417 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Organization Chart + +SPFx-1.4.1-WebPart für SharePoint Server Subscription Edition. Es erstellt aus dem SharePoint User Profile Service ein Organigramm, das ab einer frei konfigurierbaren Startperson rekursiv nach unten aufgebaut wird. + +## Funktionen + +- aktueller Benutzer oder konfigurierter Manager als Startpunkt +- rekursives Laden aller darunterliegenden Ebenen über `DirectReports` +- konfigurierbare maximale Tiefe und Personenzahl +- standardmäßig vollständig aufgeklappter Baum +- einzelne Zweige können auf- und zugeklappt werden +- Profilbild, Anzeigename, Position, Abteilung und optional Kontaktdaten +- Verlinkung auf die SharePoint-Profilseite +- kurzzeitiger In-Memory-Profilcache zur Reduzierung wiederholter REST-Aufrufe +- responsive Baumdarstellung mit SharePoint-Themefarben + +## Datenquelle und Voraussetzungen + +Das WebPart verwendet direkt: + +```text +/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor(accountName=@v) +``` + +Es benötigt keine Microsoft-Graph-Berechtigungen und keine zusätzliche SharePoint-Liste. Die User Profile Service Application muss eingerichtet sein. Benutzerprofile und insbesondere die Profilproperty `Manager` müssen aus dem Active Directory importiert werden, damit SharePoint `DirectReports` korrekt bereitstellt. + +## Startperson + +Im Property Pane kann als **Startperson / Manager** angegeben werden: + +- Claims-Login, beispielsweise `i:0#.w|CONTOSO\max.mustermann` +- klassischer Login, beispielsweise `CONTOSO\max.mustermann` +- je nach Profilkonfiguration ein UPN wie `max.mustermann@contoso.local` + +Bleibt das Feld leer, verwendet das WebPart den angemeldeten Benutzer. + +## Build-Umgebung + +- Node.js 8.17.0 +- npm 6.13.4 +- SharePoint Framework 1.4.1 + +```powershell +npm install +npm test +gulp clean +gulp bundle --ship +gulp package-solution --ship +``` + +Das Paket wird unter `sharepoint/solution/organization-chart-webpart.sppkg` erzeugt. diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..3d51bf1 --- /dev/null +++ b/config/config.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json", + "version": "2.0", + "bundles": { + "organization-chart-webpart": { + "components": [{ + "entrypoint": "./lib/webparts/organizationChart/OrganizationChartWebPart.js", + "manifest": "./src/webparts/organizationChart/OrganizationChartWebPart.manifest.json" + }] + } + }, + "externals": {}, + "localizedResources": { + "OrganizationChartWebPartStrings": "lib/webparts/organizationChart/loc/{locale}.js" + } +} diff --git a/config/copy-assets.json b/config/copy-assets.json new file mode 100644 index 0000000..36b0ac6 --- /dev/null +++ b/config/copy-assets.json @@ -0,0 +1 @@ +{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", "deployCdnPath": "temp/deploy" } diff --git a/config/package-solution.json b/config/package-solution.json new file mode 100644 index 0000000..61bc940 --- /dev/null +++ b/config/package-solution.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json", + "solution": { + "name": "organization-chart-webpart", + "title": "organization-chart-webpart", + "id": "b2f88ea8-f7da-4d0e-a945-6973bf1acd1e", + "version": "1.0.0.0", + "includeClientSideAssets": true, + "skipFeatureDeployment": true + }, + "paths": { "zippedPackage": "solution/organization-chart-webpart.sppkg" } +} diff --git a/config/serve.json b/config/serve.json new file mode 100644 index 0000000..3c539e5 --- /dev/null +++ b/config/serve.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json", + "port": 4321, + "https": true, + "initialPage": "https://localhost:5432/workbench" +} diff --git a/config/write-manifests.json b/config/write-manifests.json new file mode 100644 index 0000000..8d0a194 --- /dev/null +++ b/config/write-manifests.json @@ -0,0 +1 @@ +{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", "cdnBasePath": "" } diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..4ce2d54 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,3 @@ +'use strict'; +const build = require('@microsoft/sp-build-web'); +build.initialize(require('gulp')); diff --git a/package.json b/package.json new file mode 100644 index 0000000..0259cf2 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "organization-chart-webpart", + "version": "1.0.0", + "private": true, + "main": "lib/index.js", + "engines": { "node": ">=6.9.0 <9.0.0" }, + "scripts": { + "build": "gulp bundle", + "clean": "gulp clean", + "test": "tsc -p tests/tsconfig.json && node temp/tests/tests/OrganizationChartCore.test.js && node tests/validate-project.js", + "package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship" + }, + "dependencies": { + "@microsoft/sp-core-library": "1.4.1", + "@microsoft/sp-http": "1.4.1", + "@microsoft/sp-office-ui-fabric-core": "1.4.1", + "@microsoft/sp-webpart-base": "1.4.1", + "@types/es6-promise": "0.0.33", + "@types/react": "15.6.6", + "@types/react-dom": "15.5.6", + "@types/webpack-env": "1.13.1", + "react": "15.6.2", + "react-dom": "15.6.2" + }, + "devDependencies": { + "@microsoft/sp-build-web": "1.4.1", + "@microsoft/sp-module-interfaces": "1.4.1", + "@microsoft/sp-tslint-rules": "1.4.1", + "@microsoft/sp-webpart-workbench": "1.4.1", + "ajv": "5.2.2", + "gulp": "~3.9.1" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7af628a --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { default as OrganizationChartWebPart } from './webparts/organizationChart/OrganizationChartWebPart'; diff --git a/src/webparts/organizationChart/OrganizationChartCore.ts b/src/webparts/organizationChart/OrganizationChartCore.ts new file mode 100644 index 0000000..e9217c1 --- /dev/null +++ b/src/webparts/organizationChart/OrganizationChartCore.ts @@ -0,0 +1,50 @@ +import { IUserProfileProperty } from './OrganizationChartTypes'; + +export function normalizeAccount(value: string): string { + return String(value || '').trim(); +} + +export function profileProperty(properties: IUserProfileProperty[], name: string): string { + const expected: string = String(name || '').toLowerCase(); + for (let i: number = 0; i < properties.length; i++) { + if (String(properties[i].Key || '').toLowerCase() === expected) { return String(properties[i].Value || ''); } + } + return ''; +} + +export function uniqueAccounts(values: string[], excludedAccount: string, maximum: number): string[] { + const result: string[] = []; + const keys: { [key: string]: boolean } = {}; + const excluded: string = normalizeAccount(excludedAccount).toLowerCase(); + const limit: number = Math.min(100, Math.max(1, Math.round(Number(maximum) || 25))); + for (let i: number = 0; i < values.length && result.length < limit; i++) { + const account: string = normalizeAccount(values[i]); + const key: string = account.toLowerCase(); + if (account && key !== excluded && !keys[key]) { keys[key] = true; result.push(account); } + } + return result; +} + +export function normalizeCacheMinutes(value: number): number { + const parsed: number = Math.round(Number(value) || 15); + return Math.min(120, Math.max(0, parsed)); +} + +export function normalizeMaxDepth(value: number): number { + const parsed: number = Math.round(Number(value) || 6); + return Math.min(10, Math.max(1, parsed)); +} + +export function normalizeMaxPeople(value: number): number { + const parsed: number = Math.round(Number(value) || 100); + return Math.min(500, Math.max(10, parsed)); +} + +export function normalizeProfileUrl(value: string, webUrl: string): string { + const url: string = String(value || '').trim(); + if (!url || /[\u0000-\u001f\u007f]/.test(url) || /^\/\//.test(url)) { return ''; } + if (/^https?:\/\//i.test(url) || url.charAt(0) === '/') { return url; } + if (/^[a-z][a-z0-9+.-]*:/i.test(url)) { return ''; } + const base: string = String(webUrl || '').replace(/\/$/, ''); + return base ? base + '/' + url.replace(/^\.\//, '') : ''; +} diff --git a/src/webparts/organizationChart/OrganizationChartDataService.ts b/src/webparts/organizationChart/OrganizationChartDataService.ts new file mode 100644 index 0000000..4bd3f68 --- /dev/null +++ b/src/webparts/organizationChart/OrganizationChartDataService.ts @@ -0,0 +1,162 @@ +import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { + normalizeAccount, + normalizeCacheMinutes, + normalizeMaxDepth, + normalizeMaxPeople, + normalizeProfileUrl, + profileProperty, + uniqueAccounts +} from './OrganizationChartCore'; +import { + IOrganizationChartData, + IOrganizationNode, + IUserProfile, + IUserProfileProperty +} from './OrganizationChartTypes'; + +interface IProfileCacheEntry { + expires: number; + profile: IUserProfile; +} + +interface ITreeLoadState { + count: number; + maximum: number; + maximumDepth: number; + truncated: boolean; + visited: { [account: string]: boolean }; +} + +export class OrganizationChartDataService { + private static cache: { [account: string]: IProfileCacheEntry } = {}; + + public constructor(private context: WebPartContext) { } + + public getOrganization( + accountName: string, + maxDepth: number, + maxPeople: number, + cacheMinutes: number + ): Promise { + const account: string = normalizeAccount(accountName); + if (!account) { return Promise.reject(new Error('A user account is required.')); } + const state: ITreeLoadState = { + count: 0, + maximum: normalizeMaxPeople(maxPeople), + maximumDepth: normalizeMaxDepth(maxDepth), + truncated: false, + visited: {} + }; + return this.buildNode(account, 0, state, cacheMinutes).then((root: IOrganizationNode): IOrganizationChartData => ({ + root: root, + peopleCount: state.count, + truncated: state.truncated + })); + } + + public getProfile(accountName: string, cacheMinutes: number): Promise { + const account: string = normalizeAccount(accountName); + const key: string = account.toLowerCase(); + const cached: IProfileCacheEntry = OrganizationChartDataService.cache[key]; + if (cached && cached.expires > new Date().getTime()) { return Promise.resolve(cached.profile); } + const escaped: string = account.replace(/'/g, "''"); + const url: string = this.webUrl + + "/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor(accountName=@v)?@v='" + encodeURIComponent(escaped) + "'"; + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) + .then((response: SPHttpClientResponse): Promise => { // tslint:disable-line:no-any + if (response.ok) { return response.json(); } + return response.text().then((text: string): Promise => // tslint:disable-line:no-any + Promise.reject(new Error('User profile request failed (' + response.status + '): ' + text))); + }).then((payload: any): IUserProfile => { // tslint:disable-line:no-any + const raw: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any + const profile: IUserProfile = this.mapProfile(raw || {}); + const minutes: number = normalizeCacheMinutes(cacheMinutes); + if (minutes > 0) { + OrganizationChartDataService.cache[key] = { + profile: profile, + expires: new Date().getTime() + minutes * 60000 + }; + } + return profile; + }); + } + + private buildNode( + accountName: string, + depth: number, + state: ITreeLoadState, + cacheMinutes: number + ): Promise { + const account: string = normalizeAccount(accountName); + const key: string = account.toLowerCase(); + if (!account || state.visited[key] || state.count >= state.maximum) { + if (state.count >= state.maximum) { state.truncated = true; } + return Promise.resolve(undefined as IOrganizationNode); + } + state.visited[key] = true; + state.count++; + return this.getProfile(account, cacheMinutes).then((profile: IUserProfile): Promise => { + const node: IOrganizationNode = { profile: profile, children: [] }; + if (depth >= state.maximumDepth) { + if (profile.directReports.length) { state.truncated = true; } + return Promise.resolve(node); + } + const accounts: string[] = uniqueAccounts(profile.directReports, profile.accountName, state.maximum); + const requests: Array> = accounts.map( + (childAccount: string): Promise => + this.buildNode(childAccount, depth + 1, state, cacheMinutes) + .catch((): IOrganizationNode => undefined as IOrganizationNode) + ); + return Promise.all(requests).then((children: IOrganizationNode[]): IOrganizationNode => { + node.children = children.filter((child: IOrganizationNode): boolean => !!child) + .sort((left: IOrganizationNode, right: IOrganizationNode): number => + left.profile.displayName.localeCompare(right.profile.displayName)); + return node; + }); + }); + } + + private mapProfile(raw: any): IUserProfile { // tslint:disable-line:no-any + const properties: IUserProfileProperty[] = this.results(raw.UserProfileProperties) + .map((entry: any): IUserProfileProperty => ({ // tslint:disable-line:no-any + Key: String(entry.Key || ''), + Value: String(entry.Value || '') + })); + const account: string = String(raw.AccountName || profileProperty(properties, 'AccountName') || ''); + let pictureUrl: string = normalizeProfileUrl(String(raw.PictureUrl || ''), this.webUrl); + if (!pictureUrl && account) { + pictureUrl = this.webUrl + '/_layouts/15/userphoto.aspx?size=L&accountname=' + encodeURIComponent(account); + } + return { + accountName: account, + displayName: String(raw.DisplayName || profileProperty(properties, 'PreferredName') || account), + email: String(raw.Email || profileProperty(properties, 'WorkEmail') || ''), + title: String(raw.Title || profileProperty(properties, 'SPS-JobTitle') || ''), + department: profileProperty(properties, 'Department'), + office: profileProperty(properties, 'Office'), + workPhone: profileProperty(properties, 'WorkPhone'), + cellPhone: profileProperty(properties, 'CellPhone'), + location: profileProperty(properties, 'SPS-Location'), + managerAccount: profileProperty(properties, 'Manager'), + pictureUrl: pictureUrl, + profileUrl: normalizeProfileUrl(String(raw.UserUrl || raw.PersonalUrl || ''), this.webUrl), + directReports: this.stringResults(raw.DirectReports), + extendedManagers: this.stringResults(raw.ExtendedManagers) + }; + } + + private stringResults(value: any): string[] { // tslint:disable-line:no-any + return this.results(value).map((entry: any): string => String(entry || '')); // tslint:disable-line:no-any + } + private results(value: any): any[] { // tslint:disable-line:no-any + if (!value) { return []; } + if (Array.isArray(value.results)) { return value.results; } + return Array.isArray(value) ? value : []; + } + private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); } + private get headers(): any { // tslint:disable-line:no-any + return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; + } +} diff --git a/src/webparts/organizationChart/OrganizationChartTypes.ts b/src/webparts/organizationChart/OrganizationChartTypes.ts new file mode 100644 index 0000000..25ac6f5 --- /dev/null +++ b/src/webparts/organizationChart/OrganizationChartTypes.ts @@ -0,0 +1,32 @@ +export interface IUserProfileProperty { + Key: string; + Value: string; +} + +export interface IUserProfile { + accountName: string; + displayName: string; + email: string; + title: string; + department: string; + office: string; + workPhone: string; + cellPhone: string; + location: string; + managerAccount: string; + pictureUrl: string; + profileUrl: string; + directReports: string[]; + extendedManagers: string[]; +} + +export interface IOrganizationNode { + profile: IUserProfile; + children: IOrganizationNode[]; +} + +export interface IOrganizationChartData { + root: IOrganizationNode; + peopleCount: number; + truncated: boolean; +} diff --git a/src/webparts/organizationChart/OrganizationChartWebPart.manifest.json b/src/webparts/organizationChart/OrganizationChartWebPart.manifest.json new file mode 100644 index 0000000..78c88ed --- /dev/null +++ b/src/webparts/organizationChart/OrganizationChartWebPart.manifest.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json", + "id": "3773f12d-e51c-47ba-8f13-2594ffc75bea", + "alias": "OrganizationChartWebPart", + "componentType": "WebPart", + "version": "*", + "manifestVersion": 2, + "requiresCustomScript": false, + "preconfiguredEntries": [{ + "groupId": "5c03119e-3074-46fd-976b-c60198311f70", + "group": { "default": "Other", "de-de": "Weitere" }, + "title": { "default": "Organization Chart", "de-de": "Organigramm" }, + "description": { + "default": "Builds a downward organization chart from SharePoint user profiles.", + "de-de": "Erstellt aus SharePoint-Benutzerprofilen ein Organigramm abwärts ab einer Startperson." + }, + "officeFabricIconFontName": "Org", + "properties": { + "title": "Organization Chart", + "startUser": "", + "maxDepth": 6, + "maxPeople": 100, + "cacheMinutes": 15, + "showPictures": true, + "showContactDetails": false + } + }] +} diff --git a/src/webparts/organizationChart/OrganizationChartWebPart.ts b/src/webparts/organizationChart/OrganizationChartWebPart.ts new file mode 100644 index 0000000..990563d --- /dev/null +++ b/src/webparts/organizationChart/OrganizationChartWebPart.ts @@ -0,0 +1,121 @@ +import * as React from 'react'; +import * as ReactDom from 'react-dom'; +import { Version } from '@microsoft/sp-core-library'; +import { + BaseClientSideWebPart, + IPropertyPaneConfiguration, + PropertyPaneSlider, + PropertyPaneTextField, + PropertyPaneToggle +} from '@microsoft/sp-webpart-base'; +import * as strings from 'OrganizationChartWebPartStrings'; +import { + normalizeAccount, + normalizeCacheMinutes, + normalizeMaxDepth, + normalizeMaxPeople +} from './OrganizationChartCore'; +import { OrganizationChartDataService } from './OrganizationChartDataService'; +import { IOrganizationChartData } from './OrganizationChartTypes'; +import OrganizationChart from './components/OrganizationChart'; +import { IOrganizationChartProps } from './components/IOrganizationChartProps'; +import styles from './components/OrganizationChart.module.scss'; + +export interface IOrganizationChartWebPartProps { + title: string; + startUser: string; + maxDepth: number; + maxPeople: number; + cacheMinutes: number; + showPictures: boolean; + showContactDetails: boolean; +} + +export default class OrganizationChartWebPart extends BaseClientSideWebPart { + private service: OrganizationChartDataService; + private requestVersion: number = 0; + private disposed: boolean = false; + + public render(): void { + const account: string = normalizeAccount(this.properties.startUser || this.context.pageContext.user.loginName); + const version: number = ++this.requestVersion; + if (!account) { this.renderStatus(strings.ConfigureMessage, false); return; } + this.renderStatus(strings.LoadingMessage, false); + this.service.getOrganization( + account, + normalizeMaxDepth(this.properties.maxDepth), + normalizeMaxPeople(this.properties.maxPeople), + normalizeCacheMinutes(this.properties.cacheMinutes) + ).then((data: IOrganizationChartData): void => { + if (this.disposed || version !== this.requestVersion) { return; } + if (!data.root) { this.renderStatus(strings.EmptyMessage, false); return; } + const element: React.ReactElement = React.createElement(OrganizationChart, { + data: data, + title: this.properties.title || strings.DefaultTitle, + showPictures: this.properties.showPictures !== false, + showContactDetails: this.properties.showContactDetails === true, + reportsLabel: strings.ReportsLabel, + collapseLabel: strings.CollapseLabel, + expandLabel: strings.ExpandLabel, + truncatedLabel: strings.TruncatedLabel, + peopleCountLabel: strings.PeopleCountLabel + }); + ReactDom.render(element, this.domElement); + }).catch((error: Error): void => { + if (this.disposed || version !== this.requestVersion) { return; } + if (window.console) { console.error('[OrganizationChart] ' + strings.LoadErrorMessage, error); } + this.renderStatus(strings.LoadErrorMessage, true); + }); + } + + protected onInit(): Promise { + this.service = new OrganizationChartDataService(this.context); + return super.onInit(); + } + + protected onDispose(): void { + this.disposed = true; + this.requestVersion++; + 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.SourceGroupName, + groupFields: [ + PropertyPaneTextField('title', { label: strings.TitleFieldLabel }), + PropertyPaneTextField('startUser', { + label: strings.StartUserFieldLabel, + description: strings.StartUserFieldDescription, + placeholder: strings.StartUserPlaceholder + }), + PropertyPaneSlider('maxDepth', { label: strings.MaxDepthFieldLabel, min: 1, max: 10, step: 1 }), + PropertyPaneSlider('maxPeople', { label: strings.MaxPeopleFieldLabel, min: 10, max: 500, step: 10 }), + PropertyPaneSlider('cacheMinutes', { label: strings.CacheMinutesFieldLabel, min: 0, max: 120, step: 5 }) + ] + }, { + groupName: strings.DisplayGroupName, + groupFields: [ + PropertyPaneToggle('showPictures', { label: strings.ShowPicturesFieldLabel }), + PropertyPaneToggle('showContactDetails', { label: strings.ShowContactDetailsFieldLabel }) + ] + }] + }] + }; + } + + private renderStatus(message: string, error: boolean): void { + ReactDom.unmountComponentAtNode(this.domElement); + const status: HTMLDivElement = document.createElement('div'); + status.className = error ? styles.error : styles.status; + status.setAttribute('role', error ? 'alert' : 'status'); + status.textContent = message; + this.domElement.innerHTML = ''; + this.domElement.appendChild(status); + } +} diff --git a/src/webparts/organizationChart/components/IOrganizationChartProps.ts b/src/webparts/organizationChart/components/IOrganizationChartProps.ts new file mode 100644 index 0000000..4e8a678 --- /dev/null +++ b/src/webparts/organizationChart/components/IOrganizationChartProps.ts @@ -0,0 +1,13 @@ +import { IOrganizationChartData } from '../OrganizationChartTypes'; + +export interface IOrganizationChartProps { + data: IOrganizationChartData; + title: string; + showPictures: boolean; + showContactDetails: boolean; + reportsLabel: string; + collapseLabel: string; + expandLabel: string; + truncatedLabel: string; + peopleCountLabel: string; +} diff --git a/src/webparts/organizationChart/components/OrganizationChart.module.scss b/src/webparts/organizationChart/components/OrganizationChart.module.scss new file mode 100644 index 0000000..34ae683 --- /dev/null +++ b/src/webparts/organizationChart/components/OrganizationChart.module.scss @@ -0,0 +1,62 @@ +@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss'; + +.organizationChart { + color: "[theme:bodyText, default:#323130]"; + font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; +} +.header { align-items: baseline; display: flex; justify-content: space-between; margin-bottom: 12px; } +.heading { font-size: 24px; font-weight: 300; line-height: 32px; margin: 0; } +.count { color: "[theme:neutralSecondary, default:#605e5c]"; font-size: 12px; } +.notice { background: "[theme:neutralLighter, default:#f3f2f1]"; border-left: 3px solid "[theme:themePrimary, default:#0078d4]"; margin-bottom: 12px; padding: 8px 12px; } +.viewport { overflow: auto; padding: 4px 4px 24px; } +.tree { display: table; margin: 0 auto; min-width: max-content; } +.tree ul { display: flex; justify-content: center; list-style: none; margin: 0; padding: 22px 0 0; position: relative; } +.tree li { list-style: none; padding: 22px 8px 0; position: relative; text-align: center; } +.tree > ul { padding-top: 0; } +.tree > ul > li { padding-top: 0; } +.tree li::before, .tree li::after { border-top: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; content: ''; height: 22px; position: absolute; right: 50%; top: 0; width: 50%; } +.tree li::after { border-left: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; left: 50%; right: auto; } +.tree li:only-child::before, .tree li:only-child::after { display: none; } +.tree li:only-child { padding-top: 22px; } +.tree > ul > li:only-child { padding-top: 0; } +.tree li:first-child::before, .tree li:last-child::after { border: 0; } +.tree li:last-child::before { border-right: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; border-radius: 0 4px 0 0; } +.tree li:first-child::after { border-radius: 4px 0 0 0; } +.tree ul ul::before { border-left: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; content: ''; height: 22px; left: 50%; position: absolute; top: 0; } +.nodeWrap { display: inline-block; position: relative; } +.personCard { + background: "[theme:white, default:#ffffff]"; + border: 1px solid "[theme:neutralLight, default:#edebe9]"; + box-shadow: 0 1px 3px rgba(0, 0, 0, .12); + color: "[theme:bodyText, default:#323130]"; + display: block; + min-height: 92px; + text-align: left; + text-decoration: none; + width: 230px; +} +.personCard:hover { border-color: "[theme:themePrimary, default:#0078d4]"; color: "[theme:bodyText, default:#323130]"; } +.personCard:focus { outline: 2px solid "[theme:focusBorder, default:#605e5c]"; outline-offset: 1px; } +.cardContent { align-items: center; display: flex; min-height: 92px; padding: 12px; position: relative; } +.photoWrap { background: "[theme:themePrimary, default:#0078d4]"; border-radius: 50%; flex: 0 0 56px; height: 56px; margin-right: 12px; overflow: hidden; position: relative; } +.initials { color: #fff; font-size: 18px; left: 0; line-height: 56px; position: absolute; right: 0; text-align: center; } +.photo { height: 56px; object-fit: cover; position: relative; width: 56px; z-index: 1; } +.personText { display: block; min-width: 0; padding-bottom: 16px; } +.name { display: block; font-size: 15px; font-weight: 600; line-height: 20px; } +.jobTitle, .department, .email, .location { color: "[theme:neutralSecondary, default:#605e5c]"; display: block; font-size: 12px; line-height: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.email { color: "[theme:themePrimary, default:#0078d4]"; } +.reportCount { bottom: 8px; color: "[theme:neutralSecondary, default:#605e5c]"; font-size: 11px; position: absolute; right: 10px; } +.toggle { background: "[theme:white, default:#ffffff]"; border: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; border-radius: 50%; bottom: -11px; color: "[theme:themePrimary, default:#0078d4]"; cursor: pointer; font-size: 16px; height: 23px; left: calc(50% - 11px); line-height: 18px; padding: 0; position: absolute; width: 23px; z-index: 2; } +.toggle:hover, .toggle:focus { background: "[theme:neutralLighter, default:#f3f2f1]"; outline: none; } +.status { color: "[theme:bodyText, default:#323130]"; padding: 16px; } +.error { color: "[theme:errorText, default:#a80000]"; padding: 16px; } + +@media (max-width: 600px) { + .tree { display: block; min-width: 0; width: 100%; } + .tree ul { display: block; padding: 12px 0 0 18px; } + .tree li { padding: 12px 0 0 12px; text-align: left; } + .tree li::before, .tree li::after, .tree ul ul::before { display: none; } + .tree > ul > li, .tree li:only-child { padding-top: 0; } + .personCard { max-width: 100%; width: 260px; } + .toggle { bottom: auto; left: -11px; top: 36px; } +} diff --git a/src/webparts/organizationChart/components/OrganizationChart.tsx b/src/webparts/organizationChart/components/OrganizationChart.tsx new file mode 100644 index 0000000..24e03db --- /dev/null +++ b/src/webparts/organizationChart/components/OrganizationChart.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { IOrganizationNode, IUserProfile } from '../OrganizationChartTypes'; +import styles from './OrganizationChart.module.scss'; +import { IOrganizationChartProps } from './IOrganizationChartProps'; + +interface IOrganizationChartState { + collapsed: { [account: string]: boolean }; +} + +export default class OrganizationChart extends React.Component { + public constructor(props: IOrganizationChartProps) { + super(props); + this.state = { collapsed: {} }; + } + + public render(): React.ReactElement { + return ( +
+
+ {this.props.title &&

{this.props.title}

} + + {this.props.peopleCountLabel.replace('{0}', String(this.props.data.peopleCount))} + +
+ {this.props.data.truncated &&
{this.props.truncatedLabel}
} +
+
+
    {this.renderNode(this.props.data.root, 1)}
+
+
+
+ ); + } + + private renderNode(node: IOrganizationNode, level: number): JSX.Element { + const profile: IUserProfile = node.profile; + const key: string = profile.accountName.toLowerCase(); + const collapsed: boolean = this.state.collapsed[key] === true; + const hasChildren: boolean = node.children.length > 0; + return ( +
  • +
    + {this.renderPerson(profile)} + {hasChildren && } +
    + {hasChildren && !collapsed && +
      + {node.children.map((child: IOrganizationNode): JSX.Element => this.renderNode(child, level + 1))} +
    } +
  • + ); + } + + private renderPerson(profile: IUserProfile): JSX.Element { + const content: JSX.Element = ( + + {this.props.showPictures && + + {profile.pictureUrl && ): void => this.hideBrokenImage(event)} />} + } + + {profile.displayName} + {profile.title && {profile.title}} + {profile.department && {profile.department}} + {this.props.showContactDetails && profile.email && {profile.email}} + {this.props.showContactDetails && (profile.office || profile.location) && + {profile.office || profile.location}} + + {profile.directReports.length > 0 && + {profile.directReports.length + ' ' + this.props.reportsLabel}} + + ); + return profile.profileUrl + ? {content} + :
    {content}
    ; + } + + private toggle(key: string): void { + const collapsed: { [account: string]: boolean } = {}; + const existingKeys: string[] = Object.keys(this.state.collapsed); + for (let i: number = 0; i < existingKeys.length; i++) { + collapsed[existingKeys[i]] = this.state.collapsed[existingKeys[i]]; + } + collapsed[key] = !collapsed[key]; + this.setState({ collapsed: collapsed }); + } + + private initials(displayName: string): string { + const parts: string[] = String(displayName || '?').trim().split(/\s+/); + return (parts[0].charAt(0) + (parts.length > 1 ? parts[parts.length - 1].charAt(0) : '')).toUpperCase(); + } + + private hideBrokenImage(event: React.SyntheticEvent): void { + event.currentTarget.style.display = 'none'; + } +} diff --git a/src/webparts/organizationChart/loc/de-de.js b/src/webparts/organizationChart/loc/de-de.js new file mode 100644 index 0000000..5bacb62 --- /dev/null +++ b/src/webparts/organizationChart/loc/de-de.js @@ -0,0 +1,26 @@ +define([], function() { + return { + PropertyPaneDescription: 'Erstellt das Organigramm aus dem SharePoint User Profile Service.', + SourceGroupName: 'Startpunkt und Umfang', + DisplayGroupName: 'Darstellung', + TitleFieldLabel: 'WebPart-Titel', + StartUserFieldLabel: 'Startperson / Manager', + StartUserFieldDescription: 'Leer lassen, um beim aktuellen Benutzer zu starten. Zulässig sind Claims-Login, DOMAIN\\Benutzer oder UPN.', + StartUserPlaceholder: 'DOMAIN\\Benutzer oder benutzer@contoso.local', + MaxDepthFieldLabel: 'Maximale Ebenen nach unten', + MaxPeopleFieldLabel: 'Maximale Personenzahl', + CacheMinutesFieldLabel: 'Profilcache (Minuten, 0 = aus)', + ShowPicturesFieldLabel: 'Profilbilder anzeigen', + ShowContactDetailsFieldLabel: 'Kontaktinformationen anzeigen', + ConfigureMessage: 'Es konnte keine Startperson bestimmt werden.', + LoadingMessage: 'Organigramm wird geladen…', + EmptyMessage: 'Für die Startperson wurden keine Profildaten gefunden.', + LoadErrorMessage: 'Das Organigramm konnte nicht aus dem User Profile Service geladen werden.', + DefaultTitle: 'Organigramm', + ReportsLabel: 'Mitarbeiter', + CollapseLabel: 'Untergeordnete Personen zuklappen', + ExpandLabel: 'Untergeordnete Personen aufklappen', + TruncatedLabel: 'Das Organigramm wurde durch die konfigurierte Ebenen- oder Personengrenze gekürzt.', + PeopleCountLabel: '{0} Personen' + }; +}); diff --git a/src/webparts/organizationChart/loc/en-us.js b/src/webparts/organizationChart/loc/en-us.js new file mode 100644 index 0000000..9471890 --- /dev/null +++ b/src/webparts/organizationChart/loc/en-us.js @@ -0,0 +1,26 @@ +define([], function() { + return { + PropertyPaneDescription: 'Builds the organization chart from the SharePoint User Profile Service.', + SourceGroupName: 'Starting point and scope', + DisplayGroupName: 'Display', + TitleFieldLabel: 'Web part title', + StartUserFieldLabel: 'Starting person / manager', + StartUserFieldDescription: 'Leave empty to start with the current user. Claims login, DOMAIN\\user or UPN are supported.', + StartUserPlaceholder: 'DOMAIN\\user or user@contoso.local', + MaxDepthFieldLabel: 'Maximum levels below', + MaxPeopleFieldLabel: 'Maximum number of people', + CacheMinutesFieldLabel: 'Profile cache (minutes, 0 = off)', + ShowPicturesFieldLabel: 'Show profile pictures', + ShowContactDetailsFieldLabel: 'Show contact information', + ConfigureMessage: 'A starting person could not be determined.', + LoadingMessage: 'Loading organization chart…', + EmptyMessage: 'No profile data was found for the starting person.', + LoadErrorMessage: 'The organization chart could not be loaded from the User Profile Service.', + DefaultTitle: 'Organization Chart', + ReportsLabel: 'reports', + CollapseLabel: 'Collapse reports', + ExpandLabel: 'Expand reports', + TruncatedLabel: 'The organization chart was limited by the configured depth or person count.', + PeopleCountLabel: '{0} people' + }; +}); diff --git a/src/webparts/organizationChart/loc/myStrings.d.ts b/src/webparts/organizationChart/loc/myStrings.d.ts new file mode 100644 index 0000000..8d5b996 --- /dev/null +++ b/src/webparts/organizationChart/loc/myStrings.d.ts @@ -0,0 +1,28 @@ +declare interface IOrganizationChartWebPartStrings { + PropertyPaneDescription: string; + SourceGroupName: string; + DisplayGroupName: string; + TitleFieldLabel: string; + StartUserFieldLabel: string; + StartUserFieldDescription: string; + StartUserPlaceholder: string; + MaxDepthFieldLabel: string; + MaxPeopleFieldLabel: string; + CacheMinutesFieldLabel: string; + ShowPicturesFieldLabel: string; + ShowContactDetailsFieldLabel: string; + ConfigureMessage: string; + LoadingMessage: string; + EmptyMessage: string; + LoadErrorMessage: string; + DefaultTitle: string; + ReportsLabel: string; + CollapseLabel: string; + ExpandLabel: string; + TruncatedLabel: string; + PeopleCountLabel: string; +} +declare module 'OrganizationChartWebPartStrings' { + const strings: IOrganizationChartWebPartStrings; + export = strings; +} diff --git a/tests/OrganizationChartCore.test.ts b/tests/OrganizationChartCore.test.ts new file mode 100644 index 0000000..bca84f4 --- /dev/null +++ b/tests/OrganizationChartCore.test.ts @@ -0,0 +1,30 @@ +import { + normalizeAccount, + normalizeCacheMinutes, + normalizeMaxDepth, + normalizeMaxPeople, + normalizeProfileUrl, + profileProperty, + uniqueAccounts +} from '../src/webparts/organizationChart/OrganizationChartCore'; +import { IUserProfileProperty } from '../src/webparts/organizationChart/OrganizationChartTypes'; + +declare const console: { log(message: string): void }; +let passed: number = 0; +function assert(condition: boolean, message: string): void { if (!condition) { throw new Error(message); } passed++; } + +const properties: IUserProfileProperty[] = [ + { Key: 'Department', Value: 'IT' }, + { Key: 'Manager', Value: 'CONTOSO\\manager' } +]; +assert(normalizeAccount(' CONTOSO\\user ') === 'CONTOSO\\user', 'Account must be trimmed.'); +assert(profileProperty(properties, 'department') === 'IT', 'Profile-property lookup must ignore casing.'); +assert(profileProperty(properties, 'Office') === '', 'Missing profile properties must be empty.'); +assert(uniqueAccounts(['A', 'a', 'B', 'C'], 'B', 10).join(',') === 'A,C', 'Accounts must be unique and excludable.'); +assert(normalizeMaxDepth(0) === 6 && normalizeMaxDepth(20) === 10, 'Tree depth must be constrained.'); +assert(normalizeMaxPeople(1) === 10 && normalizeMaxPeople(900) === 500, 'Person count must be constrained.'); +assert(normalizeCacheMinutes(-1) === 0 && normalizeCacheMinutes(300) === 120, 'Cache duration must be constrained.'); +assert(normalizeProfileUrl('javascript:alert(1)', 'http://portal') === '', 'Unsafe profile URLs must be rejected.'); +assert(normalizeProfileUrl('/my/person.aspx', 'http://portal') === '/my/person.aspx', 'Server-relative URLs must be retained.'); + +console.log('Organization chart core tests passed: ' + passed); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..21eceaa --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "../temp/tests", + "rootDir": "..", + "lib": ["es5", "dom", "es2015.promise"], + "types": [], + "skipLibCheck": true + }, + "files": [ + "../src/webparts/organizationChart/OrganizationChartTypes.ts", + "../src/webparts/organizationChart/OrganizationChartCore.ts", + "OrganizationChartCore.test.ts" + ] +} diff --git a/tests/validate-project.js b/tests/validate-project.js new file mode 100644 index 0000000..94cf16e --- /dev/null +++ b/tests/validate-project.js @@ -0,0 +1,41 @@ +'use strict'; +var fs = require('fs'); +var path = require('path'); +var root = path.resolve(__dirname, '..'); +var passed = 0; +function read(file) { return fs.readFileSync(path.join(root, file), 'utf8'); } +function json(file) { return JSON.parse(read(file)); } +function assert(condition, message) { if (!condition) { throw new Error(message); } passed++; } + +var packageJson = json('package.json'); +var packageSolution = json('config/package-solution.json'); +var config = json('config/config.json'); +var manifest = json('src/webparts/organizationChart/OrganizationChartWebPart.manifest.json'); +var service = read('src/webparts/organizationChart/OrganizationChartDataService.ts'); +var webPart = read('src/webparts/organizationChart/OrganizationChartWebPart.ts'); +var component = read('src/webparts/organizationChart/components/OrganizationChart.tsx'); +var styles = read('src/webparts/organizationChart/components/OrganizationChart.module.scss'); + +assert(packageJson.name === 'organization-chart-webpart', 'Package name is inconsistent.'); +assert(packageSolution.solution.name === packageJson.name && packageSolution.solution.title === packageJson.name, + 'Solution name is inconsistent.'); +assert(packageSolution.paths.zippedPackage === 'solution/organization-chart-webpart.sppkg', 'Package file name is inconsistent.'); +assert(packageSolution.solution.includeClientSideAssets === true && packageSolution.solution.skipFeatureDeployment === true, + 'Central deployment is not configured.'); +assert(!!config.bundles['organization-chart-webpart'], 'Bundle name is inconsistent.'); +assert(manifest.componentType === 'WebPart' && manifest.alias === 'OrganizationChartWebPart', 'Manifest is invalid.'); +assert(service.indexOf('/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor') >= 0, 'User Profile REST access is missing.'); +assert(service.indexOf('profile.directReports') >= 0 && service.indexOf('this.buildNode(childAccount, depth + 1') >= 0, + 'Recursive downward organization loading is missing.'); +assert(webPart.indexOf("PropertyPaneTextField('startUser'") >= 0, 'Starting-person configuration is missing.'); +assert(webPart.indexOf("PropertyPaneSlider('maxDepth'") >= 0 && webPart.indexOf("PropertyPaneSlider('maxPeople'") >= 0, + 'Tree safety limits are missing.'); +assert(component.indexOf("role='tree'") >= 0 && component.indexOf("role='treeitem'") >= 0, + 'Accessible tree semantics are missing.'); +assert(component.indexOf('aria-expanded') >= 0, 'Accessible expand/collapse behavior is missing.'); +['bodyText', 'neutralSecondary', 'neutralLighter', 'themePrimary', 'neutralTertiary', 'white', 'neutralLight', 'focusBorder', 'errorText'] + .forEach(function(slot) { + assert(styles.indexOf('[theme:' + slot + ', default:') >= 0, 'SharePoint theme slot is missing: ' + slot); + }); + +console.log('Organization Chart project validation passed: ' + passed); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2a0877f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es5", + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "jsx": "react", + "declaration": true, + "sourceMap": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "typeRoots": ["./node_modules/@types", "./node_modules/@microsoft"], + "types": ["es6-promise", "webpack-env"], + "lib": ["es5", "dom", "es2015.collection"] + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..ce6aa16 --- /dev/null +++ b/tslint.json @@ -0,0 +1,8 @@ +{ + "extends": "@microsoft/sp-tslint-rules/base-tslint.json", + "rules": { + "class-name": false, "export-name": false, "member-access": true, + "no-console": false, "no-duplicate-variable": true, "no-shadowed-variable": true, + "semicolon": true, "typedef": false, "variable-name": false, "whitespace": false + } +}