feat: update organization chart web part to support SharePoint list as data source

- Bump version to 1.2.0 for organization-chart-webpart
- Add new interfaces and types for SharePoint list configuration
- Implement OrganizationChartListDataService to handle SharePoint list data retrieval
- Update web part properties to include data source selection and list configuration
- Modify rendering logic to accommodate both user profiles and SharePoint list data sources
- Enhance property pane with new fields for list configuration
- Update localization files for new property labels and messages
- Add validation checks for list configuration in the web part
This commit is contained in:
Torsten Brendgen
2026-08-27 00:14:22 +02:00
parent 783d4f929c
commit c19d38f851
12 changed files with 688 additions and 25 deletions
+27 -2
View File
@@ -1,10 +1,12 @@
# 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.
SPFx-1.4.1-WebPart für SharePoint Server Subscription Edition. Es erstellt aus dem User Profile Service oder einer frei zugeordneten SharePoint-Liste ein Organigramm, das ab einer konfigurierbaren Startperson rekursiv nach unten aufgebaut wird.
## Funktionen
- aktueller Benutzer oder konfigurierter Manager als Startpunkt
- wählbare Datenquelle: User Profile Service oder SharePoint-Liste
- konfigurierbarer WebPart-Titel
- Startperson über einen SharePoint-People-Picker direkt im WebPart auswählen
- rekursives Laden aller darunterliegenden Ebenen über `DirectReports`
- konfigurierbare maximale Tiefe und Personenzahl
@@ -28,7 +30,30 @@ Das WebPart verwendet direkt:
/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser
```
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.
Es benötigt keine Microsoft-Graph-Berechtigungen. Bei Verwendung des User Profile Service muss die Service Application eingerichtet sein. Benutzerprofile und insbesondere die Profilproperty `Manager` müssen aus dem Active Directory importiert werden, damit SharePoint `DirectReports` korrekt bereitstellt.
Alternativ kann im Property Pane eine SharePoint-Liste gewählt werden. Erforderlich sind Spalten für einen eindeutigen Schlüssel, den zugehörigen Manager-Schlüssel und den Anzeigenamen. Position, Abteilung, E-Mail, Standort, Telefon, Bild-URL und Profil-URL sind optional. Schlüssel- und Manager-Spalten können Textspalten oder einzelne Personenfelder sein. Die Startperson wird aus den vorhandenen Listeneinträgen gewählt; ohne Auswahl wird ein Eintrag ohne bekannten Manager automatisch als Wurzel verwendet.
Eine mögliche Listenstruktur mit Textspalten ist:
| Spalte | Beispiel | Zuordnung |
|---|---|---|
| `AccountName` | `CONTOSO\max.mustermann` | eindeutiger Schlüssel |
| `ManagerAccount` | `CONTOSO\erika.musterfrau` | Manager-Schlüssel |
| `Title` | `Max Mustermann` | Anzeigename |
| `JobTitle` | `Teamleiter` | Position, optional |
| `Department` | `IT` | Abteilung, optional |
Alternativ können für Schlüssel und Manager zwei einzelne Personenfelder verwendet werden. Entscheidend ist, dass der aus der Manager-Spalte gelesene Wert mit dem Schlüssel des übergeordneten Listeneintrags übereinstimmt.
### Verwendung mit dem Inhaltstyp-Katalog
Die Listenfelder koennen ueber den Inhaltstyp-Katalog verteilt werden. Fuer den eindeutigen Personenschluessel
und die Manager-Beziehung sollten Textspalten oder einzelne Personenfelder verwendet werden. Ein Lookup auf
eine konkrete Mitarbeiterliste ist dafuer nicht geeignet, weil ein Lookup die Listen-ID der jeweiligen
Websitesammlung enthaelt. Lookup-Spalten werden deshalb in der Spaltenzuordnung des WebParts nicht angeboten.
Empfehlenswert sind ein unveraenderlicher Mitarbeiter-/Accountschluessel als Text oder zwei Personenfelder
mit identischer Login-Darstellung.
## Startperson
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "organization-chart-webpart",
"title": "organization-chart-webpart",
"id": "b2f88ea8-f7da-4d0e-a945-6973bf1acd1e",
"version": "1.1.1.0",
"version": "1.2.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "organization-chart-webpart",
"version": "1.1.1",
"version": "1.2.0",
"private": true,
"main": "lib/index.js",
"engines": { "node": ">=6.9.0 <9.0.0" },
+4
View File
@@ -0,0 +1,4 @@
declare module '*.module.scss' {
const styles: { [className: string]: string };
export default styles;
}
@@ -0,0 +1,255 @@
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { normalizeMaxDepth, normalizeMaxPeople, normalizeProfileUrl } from './OrganizationChartCore';
import {
IOrganizationChartData,
IOrganizationChartListConfiguration,
IOrganizationChartListField,
IOrganizationChartListOption,
IOrganizationChartListRootOption,
IOrganizationNode,
IUserProfile
} from './OrganizationChartTypes';
interface IListTreeState {
count: number;
maximum: number;
maximumDepth: number;
truncated: boolean;
visited: { [key: string]: boolean };
}
export class OrganizationChartListDataService {
public constructor(private context: WebPartContext) { }
public getLists(): Promise<IOrganizationChartListOption[]> {
const url: string = this.webUrl + '/_api/web/lists?$select=Id,Title&$filter=Hidden eq false&$orderby=Title';
return this.getJson(url).then((payload: any): IOrganizationChartListOption[] => // tslint:disable-line:no-any
this.results(payload).map((list: any): IOrganizationChartListOption => ({ // tslint:disable-line:no-any
id: this.cleanGuid(list.Id),
title: String(list.Title || list.Id)
})));
}
public getFields(listId: string): Promise<IOrganizationChartListField[]> {
if (!this.isGuid(listId)) { return Promise.resolve([]); }
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) +
"')/fields?$select=InternalName,Title,TypeAsString,Hidden&$filter=Hidden eq false&$orderby=Title";
return this.getJson(url).then((payload: any): IOrganizationChartListField[] => // tslint:disable-line:no-any
this.results(payload)
.filter((field: any): boolean => this.isSupportedField(String(field.TypeAsString || ''))) // tslint:disable-line:no-any
.map((field: any): IOrganizationChartListField => ({ // tslint:disable-line:no-any
internalName: String(field.InternalName || ''),
title: String(field.Title || field.InternalName || ''),
type: String(field.TypeAsString || '')
})));
}
public getRootOptions(config: IOrganizationChartListConfiguration): Promise<IOrganizationChartListRootOption[]> {
if (!this.hasMinimumConfiguration(config, false)) { return Promise.resolve([]); }
return this.getRows(config).then((rows: any[]): IOrganizationChartListRootOption[] => { // tslint:disable-line:no-any
const options: IOrganizationChartListRootOption[] = [];
const keys: { [key: string]: boolean } = {};
for (let i: number = 0; i < rows.length; i++) {
const key: string = this.toKey(rows[i][config.keyField]);
const normalized: string = key.toLocaleLowerCase();
if (!key || keys['$' + normalized]) { continue; }
keys['$' + normalized] = true;
options.push({ key: key, text: this.toDisplayText(rows[i][config.displayNameField]) || key });
}
return options.sort((left: IOrganizationChartListRootOption, right: IOrganizationChartListRootOption): number =>
left.text.localeCompare(right.text));
});
}
public getOrganization(
config: IOrganizationChartListConfiguration,
maxDepth: number,
maxPeople: number
): Promise<IOrganizationChartData> {
if (!this.hasMinimumConfiguration(config, true)) {
return Promise.reject(new Error('The SharePoint list mapping is incomplete.'));
}
return this.getRows(config).then((rows: any[]): IOrganizationChartData => { // tslint:disable-line:no-any
const profilesByKey: { [key: string]: IUserProfile } = {};
const orderedProfiles: IUserProfile[] = [];
for (let i: number = 0; i < rows.length; i++) {
const profile: IUserProfile = this.mapRow(rows[i], config);
const normalizedKey: string = profile.accountName.toLocaleLowerCase();
if (!normalizedKey || profilesByKey['$' + normalizedKey]) { continue; }
profilesByKey['$' + normalizedKey] = profile;
orderedProfiles.push(profile);
}
for (let i: number = 0; i < orderedProfiles.length; i++) {
orderedProfiles[i].directReports = [];
}
for (let i: number = 0; i < orderedProfiles.length; i++) {
const managerKey: string = orderedProfiles[i].managerAccount.toLocaleLowerCase();
const manager: IUserProfile = profilesByKey['$' + managerKey];
if (manager && manager.accountName.toLocaleLowerCase() !== orderedProfiles[i].accountName.toLocaleLowerCase()) {
manager.directReports.push(orderedProfiles[i].accountName);
}
}
const requestedRoot: string = String(config.rootKey || '').trim().toLocaleLowerCase();
let rootProfile: IUserProfile = requestedRoot ? profilesByKey['$' + requestedRoot] : undefined as IUserProfile;
if (!rootProfile) {
const roots: IUserProfile[] = orderedProfiles.filter((profile: IUserProfile): boolean =>
!profile.managerAccount || !profilesByKey['$' + profile.managerAccount.toLocaleLowerCase()]);
rootProfile = (roots.length ? roots : orderedProfiles)
.sort((left: IUserProfile, right: IUserProfile): number => left.displayName.localeCompare(right.displayName))[0];
}
if (!rootProfile) { return { root: undefined as IOrganizationNode, peopleCount: 0, truncated: false }; }
const state: IListTreeState = {
count: 0,
maximum: normalizeMaxPeople(maxPeople),
maximumDepth: normalizeMaxDepth(maxDepth),
truncated: false,
visited: {}
};
const root: IOrganizationNode = this.buildNode(rootProfile, profilesByKey, 0, state);
return { root: root, peopleCount: state.count, truncated: state.truncated };
});
}
private buildNode(
profile: IUserProfile,
profilesByKey: { [key: string]: IUserProfile },
depth: number,
state: IListTreeState
): IOrganizationNode {
const key: string = '$' + profile.accountName.toLocaleLowerCase();
if (state.visited[key] || state.count >= state.maximum) {
if (state.count >= state.maximum) { state.truncated = true; }
return undefined as IOrganizationNode;
}
state.visited[key] = true;
state.count++;
const node: IOrganizationNode = { profile: profile, children: [] };
if (depth >= state.maximumDepth) {
if (profile.directReports.length) { state.truncated = true; }
return node;
}
for (let i: number = 0; i < profile.directReports.length; i++) {
const child: IUserProfile = profilesByKey['$' + profile.directReports[i].toLocaleLowerCase()];
if (!child) { continue; }
const childNode: IOrganizationNode = this.buildNode(child, profilesByKey, depth + 1, state);
if (childNode) { node.children.push(childNode); }
}
node.children.sort((left: IOrganizationNode, right: IOrganizationNode): number =>
left.profile.displayName.localeCompare(right.profile.displayName));
return node;
}
private mapRow(row: any, config: IOrganizationChartListConfiguration): IUserProfile { // tslint:disable-line:no-any
const keyValue: any = row[config.keyField]; // tslint:disable-line:no-any
const displayValue: any = row[config.displayNameField]; // tslint:disable-line:no-any
const key: string = this.toKey(keyValue);
const email: string = this.fieldText(row, config.emailField) || this.userProperty(displayValue, 'EMail') ||
this.userProperty(keyValue, 'EMail');
let pictureUrl: string = normalizeProfileUrl(this.fieldUrl(row, config.pictureField), this.webUrl);
if (!pictureUrl && key && this.isUserField(config, config.keyField)) {
pictureUrl = this.webUrl + '/_layouts/15/userphoto.aspx?size=L&accountname=' + encodeURIComponent(key);
}
return {
accountName: key,
displayName: this.toDisplayText(displayValue) || key,
email: email,
title: this.fieldText(row, config.jobTitleField),
department: this.fieldText(row, config.departmentField),
office: this.fieldText(row, config.officeField),
workPhone: this.fieldText(row, config.phoneField),
cellPhone: '',
location: '',
managerAccount: this.toKey(row[config.managerField]),
pictureUrl: pictureUrl,
profileUrl: normalizeProfileUrl(this.fieldUrl(row, config.profileUrlField), this.webUrl),
directReports: [],
extendedManagers: []
};
}
private getRows(config: IOrganizationChartListConfiguration): Promise<any[]> { // tslint:disable-line:no-any
if (!this.isGuid(config.listId)) { return Promise.resolve([]); }
const names: string[] = this.uniqueNames([
config.keyField, config.managerField, config.displayNameField, config.jobTitleField,
config.departmentField, config.emailField, config.officeField, config.phoneField,
config.pictureField, config.profileUrlField
]);
const selects: string[] = ['Id'];
const expands: string[] = [];
for (let i: number = 0; i < names.length; i++) {
if (this.isUserField(config, names[i])) {
selects.push(names[i] + '/Id', names[i] + '/Title', names[i] + '/Name', names[i] + '/EMail');
expands.push(names[i]);
} else {
selects.push(names[i]);
}
}
const expand: string = expands.length ? '&$expand=' + this.uniqueNames(expands).join(',') : '';
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(config.listId) + "')/items" +
'?$select=' + selects.join(',') + expand + '&$top=5000';
return this.getJson(url).then((payload: any): any[] => this.results(payload)); // tslint:disable-line:no-any
}
private fieldText(row: any, fieldName: string): string { // tslint:disable-line:no-any
return fieldName ? this.toDisplayText(row[fieldName]) : '';
}
private fieldUrl(row: any, fieldName: string): string { // tslint:disable-line:no-any
if (!fieldName) { return ''; }
const value: any = row[fieldName]; // tslint:disable-line:no-any
return value && value.Url ? String(value.Url) : this.toDisplayText(value);
}
private toKey(value: any): string { // tslint:disable-line:no-any
if (value === undefined || value === null) { return ''; }
if (typeof value === 'string' || typeof value === 'number') { return String(value).trim(); }
return String(value.Name || value.LoginName || value.EMail || value.LookupValue || value.Title || value.Id || '').trim();
}
private toDisplayText(value: any): string { // tslint:disable-line:no-any
if (value === undefined || value === null) { return ''; }
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); }
return String(value.Title || value.LookupValue || value.Description || value.Url || value.Name || '').trim();
}
private userProperty(value: any, propertyName: string): string { // tslint:disable-line:no-any
return value && value[propertyName] ? String(value[propertyName]) : '';
}
private isUserField(config: IOrganizationChartListConfiguration, internalName: string): boolean {
for (let i: number = 0; i < config.fields.length; i++) {
if (config.fields[i].internalName === internalName) { return config.fields[i].type === 'User'; }
}
return false;
}
private hasMinimumConfiguration(config: IOrganizationChartListConfiguration, includeManager: boolean): boolean {
return !!(config && this.isGuid(config.listId) && config.keyField && config.displayNameField &&
(!includeManager || config.managerField));
}
private isSupportedField(type: string): boolean {
return ['Text', 'Note', 'Choice', 'Number', 'Integer', 'Computed', 'User', 'URL'].indexOf(type) >= 0;
}
private uniqueNames(values: string[]): string[] {
const result: string[] = [];
for (let i: number = 0; i < values.length; i++) {
const value: string = String(values[i] || '').trim();
if (value && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) && result.indexOf(value) < 0) { result.push(value); }
}
return result;
}
private getJson(url: string): Promise<any> { // tslint:disable-line:no-any
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('SharePoint list request failed (' + response.status + '): ' + text)));
});
}
private results(payload: any): any[] { // tslint:disable-line:no-any
const source: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
return source && Array.isArray(source.results) ? source.results : (Array.isArray(source) ? source : []);
}
private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); }
private get headers(): any { return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; } // tslint:disable-line:no-any
private isGuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(this.cleanGuid(value)); }
private cleanGuid(value: string): string { return String(value || '').replace(/[{}]/g, '').toLowerCase(); }
}
@@ -37,3 +37,37 @@ export interface IOrganizationChartPersonSuggestion {
email: string;
loginName: string;
}
export type OrganizationChartDataSource = 'userProfile' | 'sharePointList';
export interface IOrganizationChartListOption {
id: string;
title: string;
}
export interface IOrganizationChartListField {
internalName: string;
title: string;
type: string;
}
export interface IOrganizationChartListRootOption {
key: string;
text: string;
}
export interface IOrganizationChartListConfiguration {
listId: string;
rootKey: string;
keyField: string;
managerField: string;
displayNameField: string;
jobTitleField: string;
departmentField: string;
emailField: string;
officeField: string;
phoneField: string;
pictureField: string;
profileUrlField: string;
fields: IOrganizationChartListField[];
}
@@ -17,6 +17,7 @@
"officeFabricIconFontName": "Org",
"properties": {
"title": "Organization Chart",
"dataSource": "userProfile",
"startUser": "",
"startUserAccountName": "",
"maxDepth": 6,
@@ -26,7 +27,19 @@
"showContactDetails": false,
"cardBackgroundColor": "",
"cardAccentColor": "",
"roundedCards": false
"roundedCards": false,
"listId": "",
"listRootKey": "",
"listKeyField": "",
"listManagerField": "",
"listDisplayNameField": "",
"listJobTitleField": "",
"listDepartmentField": "",
"listEmailField": "",
"listOfficeField": "",
"listPhoneField": "",
"listPictureField": "",
"listProfileUrlField": ""
}
}]
}
@@ -4,6 +4,8 @@ import { DisplayMode, Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
IPropertyPaneDropdownOption,
PropertyPaneDropdown,
PropertyPaneSlider,
PropertyPaneTextField,
PropertyPaneToggle
@@ -16,7 +18,16 @@ import {
normalizeMaxPeople
} from './OrganizationChartCore';
import { OrganizationChartDataService } from './OrganizationChartDataService';
import { IOrganizationChartData, IOrganizationChartPersonSuggestion } from './OrganizationChartTypes';
import { OrganizationChartListDataService } from './OrganizationChartListDataService';
import {
IOrganizationChartData,
IOrganizationChartListConfiguration,
IOrganizationChartListField,
IOrganizationChartListOption,
IOrganizationChartListRootOption,
IOrganizationChartPersonSuggestion,
OrganizationChartDataSource
} from './OrganizationChartTypes';
import OrganizationChart from './components/OrganizationChart';
import {
IOrganizationChartPeoplePickerProps,
@@ -27,6 +38,7 @@ import styles from './components/OrganizationChart.module.scss';
export interface IOrganizationChartWebPartProps {
title: string;
dataSource: OrganizationChartDataSource;
startUser: string;
startUserAccountName: string;
maxDepth: number;
@@ -37,10 +49,29 @@ export interface IOrganizationChartWebPartProps {
cardBackgroundColor: string;
cardAccentColor: string;
roundedCards: boolean;
listId: string;
listRootKey: string;
listKeyField: string;
listManagerField: string;
listDisplayNameField: string;
listJobTitleField: string;
listDepartmentField: string;
listEmailField: string;
listOfficeField: string;
listPhoneField: string;
listPictureField: string;
listProfileUrlField: string;
}
export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrganizationChartWebPartProps> {
private service: OrganizationChartDataService;
private listService: OrganizationChartListDataService;
private lists: IOrganizationChartListOption[] = [];
private listFields: IOrganizationChartListField[] = [];
private listRoots: IOrganizationChartListRootOption[] = [];
private loadingLists: boolean = false;
private loadingFields: boolean = false;
private loadingRoots: boolean = false;
private requestVersion: number = 0;
private disposed: boolean = false;
private pickerHost: HTMLDivElement;
@@ -49,14 +80,23 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
public render(): void {
const account: string = normalizeAccount(this.properties.startUser);
const version: number = ++this.requestVersion;
if (this.isListSource() && !this.isListConfigured()) {
this.renderStatus(strings.ListConfigureMessage, false);
return;
}
this.renderStatus(strings.LoadingMessage, false);
this.service.getOrganization(
account,
normalizeMaxDepth(this.properties.maxDepth),
normalizeMaxPeople(this.properties.maxPeople),
normalizeCacheMinutes(this.properties.cacheMinutes),
normalizeAccount(this.properties.startUserAccountName)
).then((data: IOrganizationChartData): void => {
const request: Promise<IOrganizationChartData> = this.isListSource()
? this.listService.getOrganization(
this.getListConfiguration(),
normalizeMaxDepth(this.properties.maxDepth),
normalizeMaxPeople(this.properties.maxPeople))
: this.service.getOrganization(
account,
normalizeMaxDepth(this.properties.maxDepth),
normalizeMaxPeople(this.properties.maxPeople),
normalizeCacheMinutes(this.properties.cacheMinutes),
normalizeAccount(this.properties.startUserAccountName));
request.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, {
@@ -88,7 +128,12 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
protected onInit(): Promise<void> {
this.service = new OrganizationChartDataService(this.context);
return super.onInit();
this.listService = new OrganizationChartListDataService(this.context);
return super.onInit().then((): Promise<void> => this.isListSource() ? this.loadLists() : Promise.resolve())
.then((): Promise<void> => this.isListSource() && this.properties.listId
? this.loadListFields(this.properties.listId) : Promise.resolve())
.then((): Promise<void> => this.isListSource() && this.canLoadRoots()
? this.loadListRoots() : Promise.resolve());
}
protected onDispose(): void {
@@ -99,18 +144,97 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
protected get dataVersion(): Version { return Version.parse('1.0'); }
protected onPropertyPaneConfigurationStart(): void {
if (!this.isListSource()) { return; }
if (!this.lists.length && !this.loadingLists) { this.loadLists(); }
if (this.properties.listId && !this.listFields.length && !this.loadingFields) {
this.loadListFields(this.properties.listId);
}
if (this.canLoadRoots() && !this.listRoots.length && !this.loadingRoots) { this.loadListRoots(); }
}
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void { // tslint:disable-line:no-any
super.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
if (propertyPath === 'dataSource' && newValue !== oldValue) {
this.context.propertyPane.refresh();
if (this.isListSource()) {
this.loadLists()
.then((): Promise<void> => this.properties.listId
? this.loadListFields(this.properties.listId) : Promise.resolve())
.then((): void => this.render());
} else { this.render(); }
} else if (propertyPath === 'listId' && newValue !== oldValue) {
this.resetListMapping();
this.context.propertyPane.refresh();
this.loadListFields(String(newValue || '')).then((): void => this.render());
} else if ((propertyPath === 'listKeyField' || propertyPath === 'listDisplayNameField') && newValue !== oldValue) {
this.properties.listRootKey = '';
this.listRoots = [];
this.context.propertyPane.refresh();
this.loadListRoots().then((): void => this.render());
}
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const fieldOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.NoFieldOption }];
const requiredFieldOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.SelectFieldOption }];
const listOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.SelectListOption }];
const rootOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.AutomaticRootOption }];
for (let i: number = 0; i < this.listFields.length; i++) {
const option: IPropertyPaneDropdownOption = {
key: this.listFields[i].internalName,
text: this.listFields[i].title + ' (' + this.listFields[i].internalName + ')'
};
fieldOptions.push(option);
requiredFieldOptions.push(option);
}
for (let i: number = 0; i < this.lists.length; i++) {
listOptions.push({ key: this.lists[i].id, text: this.lists[i].title });
}
for (let i: number = 0; i < this.listRoots.length; i++) {
rootOptions.push({ key: this.listRoots[i].key, text: this.listRoots[i].text });
}
const sourceFields: any[] = [ // tslint:disable-line:no-any
PropertyPaneTextField('title', { label: strings.TitleFieldLabel }),
PropertyPaneDropdown('dataSource', {
label: strings.DataSourceFieldLabel,
options: [
{ key: 'userProfile', text: strings.UserProfileSourceOption },
{ key: 'sharePointList', text: strings.SharePointListSourceOption }
]
})
];
if (this.isListSource()) {
sourceFields.push(
PropertyPaneDropdown('listId', { label: strings.ListFieldLabel, options: listOptions, disabled: this.loadingLists }),
PropertyPaneDropdown('listKeyField', { label: strings.ListKeyFieldLabel, options: requiredFieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listManagerField', { label: strings.ListManagerFieldLabel, options: requiredFieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listDisplayNameField', { label: strings.ListDisplayNameFieldLabel, options: requiredFieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listRootKey', { label: strings.ListRootFieldLabel, options: rootOptions, disabled: this.loadingRoots || !this.canLoadRoots() }),
PropertyPaneDropdown('listJobTitleField', { label: strings.ListJobTitleFieldLabel, options: fieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listDepartmentField', { label: strings.ListDepartmentFieldLabel, options: fieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listEmailField', { label: strings.ListEmailFieldLabel, options: fieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listOfficeField', { label: strings.ListOfficeFieldLabel, options: fieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listPhoneField', { label: strings.ListPhoneFieldLabel, options: fieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listPictureField', { label: strings.ListPictureFieldLabel, options: fieldOptions, disabled: this.loadingFields }),
PropertyPaneDropdown('listProfileUrlField', { label: strings.ListProfileUrlFieldLabel, options: fieldOptions, disabled: this.loadingFields })
);
}
sourceFields.push(
PropertyPaneSlider('maxDepth', { label: strings.MaxDepthFieldLabel, min: 1, max: 10, step: 1 }),
PropertyPaneSlider('maxPeople', { label: strings.MaxPeopleFieldLabel, min: 10, max: 500, step: 10 })
);
if (!this.isListSource()) {
sourceFields.push(PropertyPaneSlider('cacheMinutes', {
label: strings.CacheMinutesFieldLabel, min: 0, max: 120, step: 5
}));
}
return {
pages: [{
header: { description: strings.PropertyPaneDescription },
groups: [{
groupName: strings.SourceGroupName,
groupFields: [
PropertyPaneTextField('title', { label: strings.TitleFieldLabel }),
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 })
]
groupFields: sourceFields
}, {
groupName: strings.DisplayGroupName,
groupFields: [
@@ -143,7 +267,7 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
private prepareSurface(selectedDisplayName: string): HTMLDivElement {
this.clearSurface();
this.domElement.innerHTML = '';
if (this.displayMode === DisplayMode.Edit) {
if (this.displayMode === DisplayMode.Edit && !this.isListSource()) {
this.pickerHost = document.createElement('div');
this.domElement.appendChild(this.pickerHost);
const picker: React.ReactElement<IOrganizationChartPeoplePickerProps> =
@@ -186,4 +310,138 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
this.properties.startUserAccountName = fallback;
this.render();
}
private isListSource(): boolean {
return this.properties.dataSource === 'sharePointList';
}
private isListConfigured(): boolean {
return !!(this.properties.listId && this.properties.listKeyField &&
this.properties.listManagerField && this.properties.listDisplayNameField);
}
private canLoadRoots(): boolean {
return !!(this.properties.listId && this.properties.listKeyField && this.properties.listDisplayNameField);
}
private getListConfiguration(): IOrganizationChartListConfiguration {
return {
listId: this.properties.listId || '',
rootKey: this.properties.listRootKey || '',
keyField: this.properties.listKeyField || '',
managerField: this.properties.listManagerField || '',
displayNameField: this.properties.listDisplayNameField || '',
jobTitleField: this.properties.listJobTitleField || '',
departmentField: this.properties.listDepartmentField || '',
emailField: this.properties.listEmailField || '',
officeField: this.properties.listOfficeField || '',
phoneField: this.properties.listPhoneField || '',
pictureField: this.properties.listPictureField || '',
profileUrlField: this.properties.listProfileUrlField || '',
fields: this.listFields
};
}
private loadLists(): Promise<void> {
this.loadingLists = true;
this.context.propertyPane.refresh();
return this.listService.getLists().then((lists: IOrganizationChartListOption[]): void => {
this.lists = lists;
const selectedId: string = String(this.properties.listId || '').replace(/[{}]/g, '').toLowerCase();
if (selectedId && !lists.some((list: IOrganizationChartListOption): boolean => list.id === selectedId)) {
this.properties.listId = '';
this.resetListMapping();
}
}).catch((error: Error): void => {
this.lists = [];
if (window.console) { console.error('[OrganizationChart] Lists could not be loaded.', error); }
}).then((): void => {
this.loadingLists = false;
this.context.propertyPane.refresh();
});
}
private loadListFields(listId: string): Promise<void> {
if (!listId) { this.listFields = []; return Promise.resolve(); }
this.loadingFields = true;
this.context.propertyPane.refresh();
return this.listService.getFields(listId).then((fields: IOrganizationChartListField[]): void => {
this.listFields = fields;
this.applyListFieldDefaults();
}).catch((error: Error): void => {
this.listFields = [];
if (window.console) { console.error('[OrganizationChart] List fields could not be loaded.', error); }
}).then((): Promise<void> => {
this.loadingFields = false;
this.context.propertyPane.refresh();
return this.canLoadRoots() ? this.loadListRoots() : Promise.resolve();
});
}
private loadListRoots(): Promise<void> {
if (!this.canLoadRoots()) { this.listRoots = []; return Promise.resolve(); }
this.loadingRoots = true;
this.context.propertyPane.refresh();
return this.listService.getRootOptions(this.getListConfiguration())
.then((roots: IOrganizationChartListRootOption[]): void => {
this.listRoots = roots;
if (this.properties.listRootKey &&
!roots.some((root: IOrganizationChartListRootOption): boolean => root.key === this.properties.listRootKey)) {
this.properties.listRootKey = '';
}
}).catch((error: Error): void => {
this.listRoots = [];
if (window.console) { console.error('[OrganizationChart] List roots could not be loaded.', error); }
}).then((): void => {
this.loadingRoots = false;
this.context.propertyPane.refresh();
});
}
private applyListFieldDefaults(): void {
this.properties.listKeyField = this.validOrDefault(this.properties.listKeyField,
['AccountName', 'Email', 'Title']);
this.properties.listManagerField = this.validOrDefault(this.properties.listManagerField,
['ManagerAccount', 'Manager'], true);
this.properties.listDisplayNameField = this.validOrDefault(this.properties.listDisplayNameField,
['DisplayName', 'Title']);
this.properties.listJobTitleField = this.validOrDefault(this.properties.listJobTitleField,
['JobTitle', 'Position'], true);
this.properties.listDepartmentField = this.validOrDefault(this.properties.listDepartmentField,
['Department'], true);
this.properties.listEmailField = this.validOrDefault(this.properties.listEmailField, ['Email'], true);
this.properties.listOfficeField = this.validOrDefault(this.properties.listOfficeField,
['Office', 'Location'], true);
this.properties.listPhoneField = this.validOrDefault(this.properties.listPhoneField,
['Phone', 'WorkPhone'], true);
this.properties.listPictureField = this.validOrDefault(this.properties.listPictureField,
['PictureUrl', 'Picture'], true);
this.properties.listProfileUrlField = this.validOrDefault(this.properties.listProfileUrlField,
['ProfileUrl'], true);
}
private validOrDefault(current: string, preferredNames: string[], optional?: boolean): string {
const names: string[] = this.listFields.map((field: IOrganizationChartListField): string => field.internalName);
if (names.indexOf(current) >= 0) { return current; }
for (let i: number = 0; i < preferredNames.length; i++) {
if (names.indexOf(preferredNames[i]) >= 0) { return preferredNames[i]; }
}
return optional ? '' : (names[0] || '');
}
private resetListMapping(): void {
this.listFields = [];
this.listRoots = [];
this.properties.listRootKey = '';
this.properties.listKeyField = '';
this.properties.listManagerField = '';
this.properties.listDisplayNameField = '';
this.properties.listJobTitleField = '';
this.properties.listDepartmentField = '';
this.properties.listEmailField = '';
this.properties.listOfficeField = '';
this.properties.listPhoneField = '';
this.properties.listPictureField = '';
this.properties.listProfileUrlField = '';
}
}
+22 -2
View File
@@ -1,9 +1,28 @@
define([], function() {
return {
PropertyPaneDescription: 'Erstellt das Organigramm aus dem SharePoint User Profile Service.',
PropertyPaneDescription: 'Erstellt das Organigramm aus Benutzerprofilen oder einer SharePoint-Liste.',
SourceGroupName: 'Startpunkt und Umfang',
DisplayGroupName: 'Darstellung',
TitleFieldLabel: 'WebPart-Titel',
DataSourceFieldLabel: 'Datenquelle',
UserProfileSourceOption: 'User Profile Service',
SharePointListSourceOption: 'SharePoint-Liste',
ListFieldLabel: 'Organigramm-Liste',
SelectListOption: 'Liste auswählen...',
SelectFieldOption: 'Spalte auswählen...',
NoFieldOption: 'Keine Spalte',
AutomaticRootOption: 'Automatisch ermitteln',
ListKeyFieldLabel: 'Spalte für eindeutigen Schlüssel',
ListManagerFieldLabel: 'Spalte für Manager-Schlüssel',
ListDisplayNameFieldLabel: 'Spalte für Anzeigename',
ListRootFieldLabel: 'Startperson',
ListJobTitleFieldLabel: 'Spalte für Position',
ListDepartmentFieldLabel: 'Spalte für Abteilung',
ListEmailFieldLabel: 'Spalte für E-Mail',
ListOfficeFieldLabel: 'Spalte für Büro / Standort',
ListPhoneFieldLabel: 'Spalte für Telefon',
ListPictureFieldLabel: 'Spalte für Bild-URL',
ListProfileUrlFieldLabel: 'Spalte für Profil-URL',
StartUserFieldLabel: 'Startperson / Manager',
StartUserFieldDescription: 'Einen geprüften SharePoint-Benutzer suchen und auswählen. Leer lassen, um beim aktuellen Benutzer zu starten.',
StartUserPlaceholder: 'Name oder E-Mail-Adresse eingeben',
@@ -21,9 +40,10 @@ define([], function() {
CardAccentColorFieldLabel: 'Akzentfarbe der Karten',
ColorFieldDescription: 'Optionale Hexadezimalfarbe, zum Beispiel #ffffff.',
ConfigureMessage: 'Es konnte keine Startperson bestimmt werden.',
ListConfigureMessage: 'Wählen Sie eine SharePoint-Liste und ordnen Sie Schlüssel-, Manager- und Anzeigenamenspalte zu.',
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.',
LoadErrorMessage: 'Das Organigramm konnte nicht aus der konfigurierten Datenquelle geladen werden.',
DefaultTitle: 'Organigramm',
ReportsLabel: 'Mitarbeiter',
ExpandDetailsLabel: 'Personendetails anzeigen',
+22 -2
View File
@@ -1,9 +1,28 @@
define([], function() {
return {
PropertyPaneDescription: 'Builds the organization chart from the SharePoint User Profile Service.',
PropertyPaneDescription: 'Builds the organization chart from user profiles or a SharePoint list.',
SourceGroupName: 'Starting point and scope',
DisplayGroupName: 'Display',
TitleFieldLabel: 'Web part title',
DataSourceFieldLabel: 'Data source',
UserProfileSourceOption: 'User Profile Service',
SharePointListSourceOption: 'SharePoint list',
ListFieldLabel: 'Organization list',
SelectListOption: 'Select a list...',
SelectFieldOption: 'Select a column...',
NoFieldOption: 'No column',
AutomaticRootOption: 'Determine automatically',
ListKeyFieldLabel: 'Unique key column',
ListManagerFieldLabel: 'Manager key column',
ListDisplayNameFieldLabel: 'Display name column',
ListRootFieldLabel: 'Starting person',
ListJobTitleFieldLabel: 'Job title column',
ListDepartmentFieldLabel: 'Department column',
ListEmailFieldLabel: 'Email column',
ListOfficeFieldLabel: 'Office / location column',
ListPhoneFieldLabel: 'Phone column',
ListPictureFieldLabel: 'Picture URL column',
ListProfileUrlFieldLabel: 'Profile URL column',
StartUserFieldLabel: 'Starting person / manager',
StartUserFieldDescription: 'Search for and select a verified SharePoint user. Leave empty to start with the current user.',
StartUserPlaceholder: 'Enter a name or email address',
@@ -21,9 +40,10 @@ define([], function() {
CardAccentColorFieldLabel: 'Card accent color',
ColorFieldDescription: 'Optional hexadecimal color, for example #ffffff.',
ConfigureMessage: 'A starting person could not be determined.',
ListConfigureMessage: 'Select a SharePoint list and map the key, manager and display name columns.',
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.',
LoadErrorMessage: 'The organization chart could not be loaded from the configured data source.',
DefaultTitle: 'Organization Chart',
ReportsLabel: 'reports',
ExpandDetailsLabel: 'Show person details',
+20
View File
@@ -3,6 +3,25 @@ declare interface IOrganizationChartWebPartStrings {
SourceGroupName: string;
DisplayGroupName: string;
TitleFieldLabel: string;
DataSourceFieldLabel: string;
UserProfileSourceOption: string;
SharePointListSourceOption: string;
ListFieldLabel: string;
SelectListOption: string;
SelectFieldOption: string;
NoFieldOption: string;
AutomaticRootOption: string;
ListKeyFieldLabel: string;
ListManagerFieldLabel: string;
ListDisplayNameFieldLabel: string;
ListRootFieldLabel: string;
ListJobTitleFieldLabel: string;
ListDepartmentFieldLabel: string;
ListEmailFieldLabel: string;
ListOfficeFieldLabel: string;
ListPhoneFieldLabel: string;
ListPictureFieldLabel: string;
ListProfileUrlFieldLabel: string;
StartUserFieldLabel: string;
StartUserFieldDescription: string;
StartUserPlaceholder: string;
@@ -20,6 +39,7 @@ declare interface IOrganizationChartWebPartStrings {
CardAccentColorFieldLabel: string;
ColorFieldDescription: string;
ConfigureMessage: string;
ListConfigureMessage: string;
LoadingMessage: string;
EmptyMessage: string;
LoadErrorMessage: string;
+14
View File
@@ -12,6 +12,7 @@ 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 listService = read('src/webparts/organizationChart/OrganizationChartListDataService.ts');
var webPart = read('src/webparts/organizationChart/OrganizationChartWebPart.ts');
var component = read('src/webparts/organizationChart/components/OrganizationChart.tsx');
var picker = read('src/webparts/organizationChart/components/OrganizationChartPeoplePicker.tsx');
@@ -25,6 +26,10 @@ assert(packageSolution.solution.includeClientSideAssets === true && packageSolut
'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(packageJson.version === '1.2.0' && packageSolution.solution.version === '1.2.0.0',
'Organization Chart version is inconsistent.');
assert(webPart.indexOf("PropertyPaneTextField('title'") >= 0 && component.indexOf('styles.heading') >= 0,
'Configurable web part title is missing.');
assert(service.indexOf('/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor') >= 0, 'User Profile REST access is missing.');
assert(service.indexOf('/_api/SP.UserProfiles.PeopleManager/GetMyProperties') >= 0,
'Reliable current-user profile access is missing.');
@@ -32,9 +37,18 @@ assert(service.indexOf('ClientPeoplePickerWebServiceInterface.clientPeoplePicker
'Configured-account resolution through the SharePoint People Picker 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("PropertyPaneDropdown('dataSource'") >= 0 &&
webPart.indexOf("PropertyPaneDropdown('listId'") >= 0 &&
webPart.indexOf("PropertyPaneDropdown('listManagerField'") >= 0,
'SharePoint list data-source configuration is missing.');
assert(listService.indexOf('/_api/web/lists') >= 0 && listService.indexOf('getOrganization') >= 0 &&
listService.indexOf('manager.directReports.push') >= 0,
'SharePoint list organization tree loading is missing.');
assert(webPart.indexOf('OrganizationChartPeoplePicker') >= 0 &&
webPart.indexOf("PropertyPaneTextField('startUser'") < 0,
'The verified starting-person picker must be rendered on the canvas instead of the property pane.');
assert(webPart.indexOf('DisplayMode.Edit && !this.isListSource()') >= 0,
'The User Profile people picker must be hidden for SharePoint-list data sources.');
assert(picker.indexOf('NormalPeoplePicker') >= 0 && picker.indexOf('ValidationState.invalid') >= 0,
'The on-canvas people picker must reject unverified free-text values.');
assert(webPart.indexOf("PropertyPaneSlider('maxDepth'") >= 0 && webPart.indexOf("PropertyPaneSlider('maxPeople'") >= 0,