feat: Initialize organization chart web part with core functionality and configuration

- Added configuration files: config.json, copy-assets.json, package-solution.json, serve.json, write-manifests.json.
- Created gulpfile.js for build process.
- Implemented core logic in OrganizationChartCore.ts for account normalization and profile handling.
- Developed data service in OrganizationChartDataService.ts to fetch organization data from SharePoint User Profile Service.
- Defined types for user profiles and organization nodes in OrganizationChartTypes.ts.
- Created web part manifest for OrganizationChartWebPart.
- Built the main web part component with rendering logic and property pane configuration in OrganizationChartWebPart.ts.
- Developed React components for displaying the organization chart.
- Implemented styling for the organization chart in OrganizationChart.module.scss.
- Added localization support with English and German strings.
- Created unit tests for core functionalities in OrganizationChartCore.test.ts.
- Set up TypeScript and TSLint configurations for the project.
This commit is contained in:
Torsten Brendgen
2026-08-24 13:48:32 +02:00
commit 18fee714b8
27 changed files with 916 additions and 0 deletions
@@ -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' };
}
}