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