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.
This commit is contained in:
Torsten Brendgen
2026-08-24 13:48:32 +02:00
commit 18fee714b8
27 changed files with 916 additions and 0 deletions
+18
View File
@@ -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
+11
View File
@@ -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"
}
}
+51
View File
@@ -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.
+16
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", "deployCdnPath": "temp/deploy" }
+12
View File
@@ -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" }
}
+6
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", "cdnBasePath": "<!-- PATH TO CDN -->" }
+3
View File
@@ -0,0 +1,3 @@
'use strict';
const build = require('@microsoft/sp-build-web');
build.initialize(require('gulp'));
+33
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
export { default as OrganizationChartWebPart } from './webparts/organizationChart/OrganizationChartWebPart';
@@ -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(/^\.\//, '') : '';
}
@@ -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<IOrganizationChartData> {
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<IUserProfile> {
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<any> => { // tslint:disable-line:no-any
if (response.ok) { return response.json(); }
return response.text().then((text: string): Promise<any> => // 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<IOrganizationNode> {
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<IOrganizationNode> => {
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<Promise<IOrganizationNode>> = accounts.map(
(childAccount: string): Promise<IOrganizationNode> =>
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' };
}
}
@@ -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;
}
@@ -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
}
}]
}
@@ -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<IOrganizationChartWebPartProps> {
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<IOrganizationChartProps> = 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<void> {
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);
}
}
@@ -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;
}
@@ -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; }
}
@@ -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<IOrganizationChartProps, IOrganizationChartState> {
public constructor(props: IOrganizationChartProps) {
super(props);
this.state = { collapsed: {} };
}
public render(): React.ReactElement<IOrganizationChartProps> {
return (
<section className={styles.organizationChart} aria-label={this.props.title}>
<div className={styles.header}>
{this.props.title && <h2 className={styles.heading}>{this.props.title}</h2>}
<span className={styles.count}>
{this.props.peopleCountLabel.replace('{0}', String(this.props.data.peopleCount))}
</span>
</div>
{this.props.data.truncated && <div className={styles.notice} role='status'>{this.props.truncatedLabel}</div>}
<div className={styles.viewport}>
<div className={styles.tree}>
<ul role='tree'>{this.renderNode(this.props.data.root, 1)}</ul>
</div>
</div>
</section>
);
}
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 (
<li key={profile.accountName} role='treeitem' aria-level={level}
aria-expanded={hasChildren ? !collapsed : undefined}>
<div className={styles.nodeWrap}>
{this.renderPerson(profile)}
{hasChildren && <button type='button' className={styles.toggle}
title={collapsed ? this.props.expandLabel : this.props.collapseLabel}
aria-label={(collapsed ? this.props.expandLabel : this.props.collapseLabel) + ': ' + profile.displayName}
aria-expanded={!collapsed} onClick={(): void => this.toggle(key)}>
{collapsed ? '+' : '−'}
</button>}
</div>
{hasChildren && !collapsed &&
<ul role='group'>
{node.children.map((child: IOrganizationNode): JSX.Element => this.renderNode(child, level + 1))}
</ul>}
</li>
);
}
private renderPerson(profile: IUserProfile): JSX.Element {
const content: JSX.Element = (
<span className={styles.cardContent}>
{this.props.showPictures && <span className={styles.photoWrap}>
<span className={styles.initials} aria-hidden='true'>{this.initials(profile.displayName)}</span>
{profile.pictureUrl && <img className={styles.photo} src={profile.pictureUrl} alt=''
onError={(event: React.SyntheticEvent<HTMLImageElement>): void => this.hideBrokenImage(event)} />}
</span>}
<span className={styles.personText}>
<span className={styles.name}>{profile.displayName}</span>
{profile.title && <span className={styles.jobTitle}>{profile.title}</span>}
{profile.department && <span className={styles.department}>{profile.department}</span>}
{this.props.showContactDetails && profile.email && <span className={styles.email}>{profile.email}</span>}
{this.props.showContactDetails && (profile.office || profile.location) &&
<span className={styles.location}>{profile.office || profile.location}</span>}
</span>
{profile.directReports.length > 0 &&
<span className={styles.reportCount}>{profile.directReports.length + ' ' + this.props.reportsLabel}</span>}
</span>
);
return profile.profileUrl
? <a className={styles.personCard} href={profile.profileUrl}>{content}</a>
: <div className={styles.personCard}>{content}</div>;
}
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<HTMLImageElement>): void {
event.currentTarget.style.display = 'none';
}
}
@@ -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'
};
});
@@ -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'
};
});
+28
View File
@@ -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;
}
+30
View File
@@ -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);
+17
View File
@@ -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"
]
}
+41
View File
@@ -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);
+16
View File
@@ -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"]
}
}
+8
View File
@@ -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
}
}