From c19d38f8513b5c9ae88a6b571b8445512cd2f0aa Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Thu, 27 Aug 2026 00:14:22 +0200 Subject: [PATCH] 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 --- README.md | 29 +- config/package-solution.json | 2 +- package.json | 2 +- src/declarations.d.ts | 4 + .../OrganizationChartListDataService.ts | 255 +++++++++++++++ .../OrganizationChartTypes.ts | 34 ++ .../OrganizationChartWebPart.manifest.json | 15 +- .../OrganizationChartWebPart.ts | 290 +++++++++++++++++- src/webparts/organizationChart/loc/de-de.js | 24 +- src/webparts/organizationChart/loc/en-us.js | 24 +- .../organizationChart/loc/myStrings.d.ts | 20 ++ tests/validate-project.js | 14 + 12 files changed, 688 insertions(+), 25 deletions(-) create mode 100644 src/declarations.d.ts create mode 100644 src/webparts/organizationChart/OrganizationChartListDataService.ts diff --git a/README.md b/README.md index 881a568..695f2c6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/package-solution.json b/config/package-solution.json index 362a6be..18d8fae 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -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 }, diff --git a/package.json b/package.json index 4fe3ae9..624344f 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 0000000..f8b65df --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.scss' { + const styles: { [className: string]: string }; + export default styles; +} diff --git a/src/webparts/organizationChart/OrganizationChartListDataService.ts b/src/webparts/organizationChart/OrganizationChartListDataService.ts new file mode 100644 index 0000000..e19530c --- /dev/null +++ b/src/webparts/organizationChart/OrganizationChartListDataService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { // 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 { // tslint:disable-line:no-any + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) + .then((response: SPHttpClientResponse): Promise => { // tslint:disable-line:no-any + if (response.ok) { return response.json(); } + return response.text().then((text: string): Promise => // 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(); } +} diff --git a/src/webparts/organizationChart/OrganizationChartTypes.ts b/src/webparts/organizationChart/OrganizationChartTypes.ts index 1f70068..70b7616 100644 --- a/src/webparts/organizationChart/OrganizationChartTypes.ts +++ b/src/webparts/organizationChart/OrganizationChartTypes.ts @@ -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[]; +} diff --git a/src/webparts/organizationChart/OrganizationChartWebPart.manifest.json b/src/webparts/organizationChart/OrganizationChartWebPart.manifest.json index 1498c17..d885815 100644 --- a/src/webparts/organizationChart/OrganizationChartWebPart.manifest.json +++ b/src/webparts/organizationChart/OrganizationChartWebPart.manifest.json @@ -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": "" } }] } diff --git a/src/webparts/organizationChart/OrganizationChartWebPart.ts b/src/webparts/organizationChart/OrganizationChartWebPart.ts index e7634dc..399e07b 100644 --- a/src/webparts/organizationChart/OrganizationChartWebPart.ts +++ b/src/webparts/organizationChart/OrganizationChartWebPart.ts @@ -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 { 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 { + const request: Promise = 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 = React.createElement(OrganizationChart, { @@ -88,7 +128,12 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart { this.service = new OrganizationChartDataService(this.context); - return super.onInit(); + this.listService = new OrganizationChartListDataService(this.context); + return super.onInit().then((): Promise => this.isListSource() ? this.loadLists() : Promise.resolve()) + .then((): Promise => this.isListSource() && this.properties.listId + ? this.loadListFields(this.properties.listId) : Promise.resolve()) + .then((): Promise => this.isListSource() && this.canLoadRoots() + ? this.loadListRoots() : Promise.resolve()); } protected onDispose(): void { @@ -99,18 +144,97 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart => 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 = @@ -186,4 +310,138 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart { + 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 { + 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 => { + this.loadingFields = false; + this.context.propertyPane.refresh(); + return this.canLoadRoots() ? this.loadListRoots() : Promise.resolve(); + }); + } + + private loadListRoots(): Promise { + 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 = ''; + } } diff --git a/src/webparts/organizationChart/loc/de-de.js b/src/webparts/organizationChart/loc/de-de.js index 3c22df5..ba6ca61 100644 --- a/src/webparts/organizationChart/loc/de-de.js +++ b/src/webparts/organizationChart/loc/de-de.js @@ -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', diff --git a/src/webparts/organizationChart/loc/en-us.js b/src/webparts/organizationChart/loc/en-us.js index cac3f8f..df14a73 100644 --- a/src/webparts/organizationChart/loc/en-us.js +++ b/src/webparts/organizationChart/loc/en-us.js @@ -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', diff --git a/src/webparts/organizationChart/loc/myStrings.d.ts b/src/webparts/organizationChart/loc/myStrings.d.ts index f3ac245..3efbe21 100644 --- a/src/webparts/organizationChart/loc/myStrings.d.ts +++ b/src/webparts/organizationChart/loc/myStrings.d.ts @@ -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; diff --git a/tests/validate-project.js b/tests/validate-project.js index fc5a0c8..2be3d67 100644 --- a/tests/validate-project.js +++ b/tests/validate-project.js @@ -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,