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:
+18
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", "deployCdnPath": "temp/deploy" }
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", "cdnBasePath": "<!-- PATH TO CDN -->" }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
'use strict';
|
||||||
|
const build = require('@microsoft/sp-build-web');
|
||||||
|
build.initialize(require('gulp'));
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user