feat: Implement SharePoint People Picker for selecting starting person and update version to 1.0.3
This commit is contained in:
@@ -5,6 +5,7 @@ SPFx-1.4.1-WebPart für SharePoint Server Subscription Edition. Es erstellt aus
|
|||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- aktueller Benutzer oder konfigurierter Manager als Startpunkt
|
- aktueller Benutzer oder konfigurierter Manager als Startpunkt
|
||||||
|
- Startperson über einen SharePoint-People-Picker direkt im WebPart auswählen
|
||||||
- rekursives Laden aller darunterliegenden Ebenen über `DirectReports`
|
- rekursives Laden aller darunterliegenden Ebenen über `DirectReports`
|
||||||
- konfigurierbare maximale Tiefe und Personenzahl
|
- konfigurierbare maximale Tiefe und Personenzahl
|
||||||
- standardmäßig vollständig aufgeklappter Baum
|
- standardmäßig vollständig aufgeklappter Baum
|
||||||
@@ -20,19 +21,16 @@ Das WebPart verwendet direkt:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor(accountName=@v)
|
/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor(accountName=@v)
|
||||||
|
/_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 und keine zusätzliche SharePoint-Liste. Die User Profile Service Application muss eingerichtet sein. Benutzerprofile und insbesondere die Profilproperty `Manager` müssen aus dem Active Directory importiert werden, damit SharePoint `DirectReports` korrekt bereitstellt.
|
||||||
|
|
||||||
## Startperson
|
## Startperson
|
||||||
|
|
||||||
Im Property Pane kann als **Startperson / Manager** angegeben werden:
|
Im Bearbeitungsmodus der modernen Seite wird direkt oberhalb des Organigramms ein People Picker angezeigt. Dort kann die Startperson per Name oder E-Mail-Adresse gesucht werden. Das WebPart akzeptiert nur einen von SharePoint aufgelösten Benutzer und prüft bei der Auswahl zusätzlich, ob dessen Profil über den User Profile Service gelesen werden kann. Gespeichert werden der kanonische Login und der Accountname als Fallback.
|
||||||
|
|
||||||
- Claims-Login, beispielsweise `i:0#.w|CONTOSO\max.mustermann`
|
Wird die Auswahl entfernt, verwendet das WebPart wieder den angemeldeten Benutzer. Im Anzeigemodus ist der People Picker nicht sichtbar.
|
||||||
- klassischer Login, beispielsweise `CONTOSO\max.mustermann`
|
|
||||||
- je nach Profilkonfiguration ein UPN wie `max.mustermann@contoso.local`
|
|
||||||
|
|
||||||
Bleibt das Feld leer, verwendet das WebPart den angemeldeten Benutzer.
|
|
||||||
|
|
||||||
## Build-Umgebung
|
## Build-Umgebung
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"name": "organization-chart-webpart",
|
"name": "organization-chart-webpart",
|
||||||
"title": "organization-chart-webpart",
|
"title": "organization-chart-webpart",
|
||||||
"id": "b2f88ea8-f7da-4d0e-a945-6973bf1acd1e",
|
"id": "b2f88ea8-f7da-4d0e-a945-6973bf1acd1e",
|
||||||
"version": "1.0.2.0",
|
"version": "1.0.3.0",
|
||||||
"includeClientSideAssets": true,
|
"includeClientSideAssets": true,
|
||||||
"skipFeatureDeployment": true
|
"skipFeatureDeployment": true
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "organization-chart-webpart",
|
"name": "organization-chart-webpart",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"engines": { "node": ">=6.9.0 <9.0.0" },
|
"engines": { "node": ">=6.9.0 <9.0.0" },
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@types/react": "15.6.6",
|
"@types/react": "15.6.6",
|
||||||
"@types/react-dom": "15.5.6",
|
"@types/react-dom": "15.5.6",
|
||||||
"@types/webpack-env": "1.13.1",
|
"@types/webpack-env": "1.13.1",
|
||||||
|
"office-ui-fabric-react": "5.21.0",
|
||||||
"react": "15.6.2",
|
"react": "15.6.2",
|
||||||
"react-dom": "15.6.2"
|
"react-dom": "15.6.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from './OrganizationChartCore';
|
} from './OrganizationChartCore';
|
||||||
import {
|
import {
|
||||||
IOrganizationChartData,
|
IOrganizationChartData,
|
||||||
|
IOrganizationChartPersonSuggestion,
|
||||||
IOrganizationNode,
|
IOrganizationNode,
|
||||||
IUserProfile,
|
IUserProfile,
|
||||||
IUserProfileProperty
|
IUserProfileProperty
|
||||||
@@ -35,6 +36,7 @@ interface IPeoplePickerEntityData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface IPeoplePickerEntity {
|
interface IPeoplePickerEntity {
|
||||||
|
DisplayText?: string;
|
||||||
EntityData?: IPeoplePickerEntityData;
|
EntityData?: IPeoplePickerEntityData;
|
||||||
IsResolved?: boolean;
|
IsResolved?: boolean;
|
||||||
Key?: string;
|
Key?: string;
|
||||||
@@ -49,7 +51,8 @@ export class OrganizationChartDataService {
|
|||||||
accountName: string,
|
accountName: string,
|
||||||
maxDepth: number,
|
maxDepth: number,
|
||||||
maxPeople: number,
|
maxPeople: number,
|
||||||
cacheMinutes: number
|
cacheMinutes: number,
|
||||||
|
alternateAccountName?: string
|
||||||
): Promise<IOrganizationChartData> {
|
): Promise<IOrganizationChartData> {
|
||||||
const account: string = normalizeAccount(accountName);
|
const account: string = normalizeAccount(accountName);
|
||||||
const state: ITreeLoadState = {
|
const state: ITreeLoadState = {
|
||||||
@@ -60,7 +63,7 @@ export class OrganizationChartDataService {
|
|||||||
visited: {}
|
visited: {}
|
||||||
};
|
};
|
||||||
const rootRequest: Promise<IUserProfile> = account
|
const rootRequest: Promise<IUserProfile> = account
|
||||||
? this.getConfiguredProfile(account, cacheMinutes)
|
? this.getConfiguredProfile(account, cacheMinutes, alternateAccountName)
|
||||||
: this.getMyProfile(cacheMinutes);
|
: this.getMyProfile(cacheMinutes);
|
||||||
return rootRequest
|
return rootRequest
|
||||||
.then((profile: IUserProfile): Promise<IOrganizationNode> =>
|
.then((profile: IUserProfile): Promise<IOrganizationNode> =>
|
||||||
@@ -94,11 +97,39 @@ export class OrganizationChartDataService {
|
|||||||
return this.requestProfile(url, key, cacheMinutes);
|
return this.requestProfile(url, key, cacheMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getConfiguredProfile(accountName: string, cacheMinutes: number): Promise<IUserProfile> {
|
public searchPeople(query: string): Promise<IOrganizationChartPersonSuggestion[]> {
|
||||||
|
const searchText: string = normalizeAccount(query);
|
||||||
|
if (searchText.length < 2) { return Promise.resolve([]); }
|
||||||
|
return this.requestPeoplePicker(searchText).then(
|
||||||
|
(entities: IPeoplePickerEntity[]): IOrganizationChartPersonSuggestion[] =>
|
||||||
|
entities.filter((entity: IPeoplePickerEntity): boolean =>
|
||||||
|
entity.IsResolved !== false && !!normalizeAccount(entity.Key))
|
||||||
|
.map((entity: IPeoplePickerEntity): IOrganizationChartPersonSuggestion => ({
|
||||||
|
accountName: normalizeAccount(entity.EntityData && entity.EntityData.AccountName),
|
||||||
|
displayName: normalizeAccount(entity.DisplayText) || normalizeAccount(entity.Key),
|
||||||
|
email: normalizeAccount(entity.EntityData && entity.EntityData.Email),
|
||||||
|
loginName: normalizeAccount(entity.Key)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public validatePerson(loginName: string, accountName: string, cacheMinutes: number): Promise<boolean> {
|
||||||
|
const candidates: string[] = uniqueAccounts([loginName, accountName], '', 2);
|
||||||
|
return this.getFirstAvailableProfile(candidates, cacheMinutes, 0)
|
||||||
|
.then((): boolean => true, (): boolean => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfiguredProfile(
|
||||||
|
accountName: string,
|
||||||
|
cacheMinutes: number,
|
||||||
|
alternateAccountName?: string
|
||||||
|
): Promise<IUserProfile> {
|
||||||
return this.resolveAccountNames(accountName)
|
return this.resolveAccountNames(accountName)
|
||||||
.catch((): string[] => [accountName])
|
.catch((): string[] => [accountName])
|
||||||
.then((accounts: string[]): Promise<IUserProfile> =>
|
.then((accounts: string[]): Promise<IUserProfile> => {
|
||||||
this.getFirstAvailableProfile(accounts, cacheMinutes, 0));
|
const candidates: string[] = uniqueAccounts(accounts.concat([alternateAccountName]), '', accounts.length + 1);
|
||||||
|
return this.getFirstAvailableProfile(candidates, cacheMinutes, 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFirstAvailableProfile(
|
private getFirstAvailableProfile(
|
||||||
@@ -116,6 +147,29 @@ export class OrganizationChartDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveAccountNames(accountName: string): Promise<string[]> {
|
private resolveAccountNames(accountName: string): Promise<string[]> {
|
||||||
|
return this.searchPeople(accountName).then(
|
||||||
|
(suggestions: IOrganizationChartPersonSuggestion[]): string[] => {
|
||||||
|
const input: string = normalizeAccount(accountName).toLowerCase();
|
||||||
|
const matching: IOrganizationChartPersonSuggestion[] = suggestions.filter(
|
||||||
|
(suggestion: IOrganizationChartPersonSuggestion): boolean =>
|
||||||
|
suggestion.accountName.toLowerCase() === input ||
|
||||||
|
suggestion.email.toLowerCase() === input ||
|
||||||
|
suggestion.loginName.toLowerCase() === input
|
||||||
|
);
|
||||||
|
const selected: IOrganizationChartPersonSuggestion[] = matching.length
|
||||||
|
? matching
|
||||||
|
: (suggestions.length === 1 ? suggestions : []);
|
||||||
|
const candidates: string[] = [];
|
||||||
|
selected.forEach((suggestion: IOrganizationChartPersonSuggestion): void => {
|
||||||
|
candidates.push(suggestion.loginName);
|
||||||
|
candidates.push(suggestion.accountName);
|
||||||
|
});
|
||||||
|
candidates.push(normalizeAccount(accountName));
|
||||||
|
return uniqueAccounts(candidates, '', candidates.length || 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestPeoplePicker(accountName: string): Promise<IPeoplePickerEntity[]> {
|
||||||
const url: string = this.webUrl +
|
const url: string = this.webUrl +
|
||||||
'/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser';
|
'/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser';
|
||||||
const body: string = JSON.stringify({
|
const body: string = JSON.stringify({
|
||||||
@@ -140,30 +194,14 @@ export class OrganizationChartDataService {
|
|||||||
if (response.ok) { return response.json(); }
|
if (response.ok) { return response.json(); }
|
||||||
return response.text().then((text: string): Promise<any> => // tslint:disable-line:no-any
|
return response.text().then((text: string): Promise<any> => // tslint:disable-line:no-any
|
||||||
Promise.reject(new Error('People Picker request failed (' + response.status + '): ' + text)));
|
Promise.reject(new Error('People Picker request failed (' + response.status + '): ' + text)));
|
||||||
}).then((payload: any): string[] => { // tslint:disable-line:no-any
|
}).then((payload: any): IPeoplePickerEntity[] => { // tslint:disable-line:no-any
|
||||||
const data: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
|
const data: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
|
||||||
const serialized: string = String(
|
const serialized: string = String(
|
||||||
data && data.ClientPeoplePickerSearchUser !== undefined
|
data && data.ClientPeoplePickerSearchUser !== undefined
|
||||||
? data.ClientPeoplePickerSearchUser
|
? data.ClientPeoplePickerSearchUser
|
||||||
: (data && data.value !== undefined ? data.value : '[]')
|
: (data && data.value !== undefined ? data.value : '[]')
|
||||||
);
|
);
|
||||||
let entities: IPeoplePickerEntity[] = [];
|
try { return JSON.parse(serialized) as IPeoplePickerEntity[]; } catch (error) { return []; }
|
||||||
try { entities = JSON.parse(serialized) as IPeoplePickerEntity[]; } catch (error) { entities = []; }
|
|
||||||
const input: string = normalizeAccount(accountName).toLowerCase();
|
|
||||||
const matching: IPeoplePickerEntity[] = entities.filter((entity: IPeoplePickerEntity): boolean => {
|
|
||||||
const account: string = normalizeAccount(entity.EntityData && entity.EntityData.AccountName).toLowerCase();
|
|
||||||
const email: string = normalizeAccount(entity.EntityData && entity.EntityData.Email).toLowerCase();
|
|
||||||
const key: string = normalizeAccount(entity.Key).toLowerCase();
|
|
||||||
return entity.IsResolved !== false && (account === input || email === input || key === input);
|
|
||||||
});
|
|
||||||
const selected: IPeoplePickerEntity[] = matching.length ? matching : (entities.length === 1 ? entities : []);
|
|
||||||
const candidates: string[] = [];
|
|
||||||
selected.forEach((entity: IPeoplePickerEntity): void => {
|
|
||||||
candidates.push(normalizeAccount(entity.Key));
|
|
||||||
candidates.push(normalizeAccount(entity.EntityData && entity.EntityData.AccountName));
|
|
||||||
});
|
|
||||||
candidates.push(normalizeAccount(accountName));
|
|
||||||
return uniqueAccounts(candidates, '', candidates.length || 1);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,10 @@ export interface IOrganizationChartData {
|
|||||||
peopleCount: number;
|
peopleCount: number;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IOrganizationChartPersonSuggestion {
|
||||||
|
accountName: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
loginName: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"title": "Organization Chart",
|
"title": "Organization Chart",
|
||||||
"startUser": "",
|
"startUser": "",
|
||||||
|
"startUserAccountName": "",
|
||||||
"maxDepth": 6,
|
"maxDepth": 6,
|
||||||
"maxPeople": 100,
|
"maxPeople": 100,
|
||||||
"cacheMinutes": 15,
|
"cacheMinutes": 15,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as ReactDom from 'react-dom';
|
import * as ReactDom from 'react-dom';
|
||||||
import { Version } from '@microsoft/sp-core-library';
|
import { DisplayMode, Version } from '@microsoft/sp-core-library';
|
||||||
import {
|
import {
|
||||||
BaseClientSideWebPart,
|
BaseClientSideWebPart,
|
||||||
IPropertyPaneConfiguration,
|
IPropertyPaneConfiguration,
|
||||||
@@ -16,14 +16,19 @@ import {
|
|||||||
normalizeMaxPeople
|
normalizeMaxPeople
|
||||||
} from './OrganizationChartCore';
|
} from './OrganizationChartCore';
|
||||||
import { OrganizationChartDataService } from './OrganizationChartDataService';
|
import { OrganizationChartDataService } from './OrganizationChartDataService';
|
||||||
import { IOrganizationChartData } from './OrganizationChartTypes';
|
import { IOrganizationChartData, IOrganizationChartPersonSuggestion } from './OrganizationChartTypes';
|
||||||
import OrganizationChart from './components/OrganizationChart';
|
import OrganizationChart from './components/OrganizationChart';
|
||||||
|
import {
|
||||||
|
IOrganizationChartPeoplePickerProps,
|
||||||
|
OrganizationChartPeoplePicker
|
||||||
|
} from './components/OrganizationChartPeoplePicker';
|
||||||
import { IOrganizationChartProps } from './components/IOrganizationChartProps';
|
import { IOrganizationChartProps } from './components/IOrganizationChartProps';
|
||||||
import styles from './components/OrganizationChart.module.scss';
|
import styles from './components/OrganizationChart.module.scss';
|
||||||
|
|
||||||
export interface IOrganizationChartWebPartProps {
|
export interface IOrganizationChartWebPartProps {
|
||||||
title: string;
|
title: string;
|
||||||
startUser: string;
|
startUser: string;
|
||||||
|
startUserAccountName: string;
|
||||||
maxDepth: number;
|
maxDepth: number;
|
||||||
maxPeople: number;
|
maxPeople: number;
|
||||||
cacheMinutes: number;
|
cacheMinutes: number;
|
||||||
@@ -35,6 +40,8 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
|
|||||||
private service: OrganizationChartDataService;
|
private service: OrganizationChartDataService;
|
||||||
private requestVersion: number = 0;
|
private requestVersion: number = 0;
|
||||||
private disposed: boolean = false;
|
private disposed: boolean = false;
|
||||||
|
private pickerHost: HTMLDivElement;
|
||||||
|
private contentHost: HTMLDivElement;
|
||||||
|
|
||||||
public render(): void {
|
public render(): void {
|
||||||
const account: string = normalizeAccount(this.properties.startUser);
|
const account: string = normalizeAccount(this.properties.startUser);
|
||||||
@@ -44,7 +51,8 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
|
|||||||
account,
|
account,
|
||||||
normalizeMaxDepth(this.properties.maxDepth),
|
normalizeMaxDepth(this.properties.maxDepth),
|
||||||
normalizeMaxPeople(this.properties.maxPeople),
|
normalizeMaxPeople(this.properties.maxPeople),
|
||||||
normalizeCacheMinutes(this.properties.cacheMinutes)
|
normalizeCacheMinutes(this.properties.cacheMinutes),
|
||||||
|
normalizeAccount(this.properties.startUserAccountName)
|
||||||
).then((data: IOrganizationChartData): void => {
|
).then((data: IOrganizationChartData): void => {
|
||||||
if (this.disposed || version !== this.requestVersion) { return; }
|
if (this.disposed || version !== this.requestVersion) { return; }
|
||||||
if (!data.root) { this.renderStatus(strings.EmptyMessage, false); return; }
|
if (!data.root) { this.renderStatus(strings.EmptyMessage, false); return; }
|
||||||
@@ -59,7 +67,8 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
|
|||||||
truncatedLabel: strings.TruncatedLabel,
|
truncatedLabel: strings.TruncatedLabel,
|
||||||
peopleCountLabel: strings.PeopleCountLabel
|
peopleCountLabel: strings.PeopleCountLabel
|
||||||
});
|
});
|
||||||
ReactDom.render(element, this.domElement);
|
const contentHost: HTMLDivElement = this.prepareSurface(data.root.profile.displayName);
|
||||||
|
ReactDom.render(element, contentHost);
|
||||||
}).catch((error: Error): void => {
|
}).catch((error: Error): void => {
|
||||||
if (this.disposed || version !== this.requestVersion) { return; }
|
if (this.disposed || version !== this.requestVersion) { return; }
|
||||||
if (window.console) { console.error('[OrganizationChart] ' + strings.LoadErrorMessage, error); }
|
if (window.console) { console.error('[OrganizationChart] ' + strings.LoadErrorMessage, error); }
|
||||||
@@ -75,7 +84,7 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
|
|||||||
protected onDispose(): void {
|
protected onDispose(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.requestVersion++;
|
this.requestVersion++;
|
||||||
ReactDom.unmountComponentAtNode(this.domElement);
|
this.clearSurface();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get dataVersion(): Version { return Version.parse('1.0'); }
|
protected get dataVersion(): Version { return Version.parse('1.0'); }
|
||||||
@@ -88,11 +97,6 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
|
|||||||
groupName: strings.SourceGroupName,
|
groupName: strings.SourceGroupName,
|
||||||
groupFields: [
|
groupFields: [
|
||||||
PropertyPaneTextField('title', { label: strings.TitleFieldLabel }),
|
PropertyPaneTextField('title', { label: strings.TitleFieldLabel }),
|
||||||
PropertyPaneTextField('startUser', {
|
|
||||||
label: strings.StartUserFieldLabel,
|
|
||||||
description: strings.StartUserFieldDescription,
|
|
||||||
placeholder: strings.StartUserPlaceholder
|
|
||||||
}),
|
|
||||||
PropertyPaneSlider('maxDepth', { label: strings.MaxDepthFieldLabel, min: 1, max: 10, step: 1 }),
|
PropertyPaneSlider('maxDepth', { label: strings.MaxDepthFieldLabel, min: 1, max: 10, step: 1 }),
|
||||||
PropertyPaneSlider('maxPeople', { label: strings.MaxPeopleFieldLabel, min: 10, max: 500, step: 10 }),
|
PropertyPaneSlider('maxPeople', { label: strings.MaxPeopleFieldLabel, min: 10, max: 500, step: 10 }),
|
||||||
PropertyPaneSlider('cacheMinutes', { label: strings.CacheMinutesFieldLabel, min: 0, max: 120, step: 5 })
|
PropertyPaneSlider('cacheMinutes', { label: strings.CacheMinutesFieldLabel, min: 0, max: 120, step: 5 })
|
||||||
@@ -109,12 +113,61 @@ export default class OrganizationChartWebPart extends BaseClientSideWebPart<IOrg
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderStatus(message: string, error: boolean): void {
|
private renderStatus(message: string, error: boolean): void {
|
||||||
ReactDom.unmountComponentAtNode(this.domElement);
|
const contentHost: HTMLDivElement = this.prepareSurface(this.properties.startUser);
|
||||||
const status: HTMLDivElement = document.createElement('div');
|
const status: HTMLDivElement = document.createElement('div');
|
||||||
status.className = error ? styles.error : styles.status;
|
status.className = error ? styles.error : styles.status;
|
||||||
status.setAttribute('role', error ? 'alert' : 'status');
|
status.setAttribute('role', error ? 'alert' : 'status');
|
||||||
status.textContent = message;
|
status.textContent = message;
|
||||||
|
contentHost.appendChild(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareSurface(selectedDisplayName: string): HTMLDivElement {
|
||||||
|
this.clearSurface();
|
||||||
this.domElement.innerHTML = '';
|
this.domElement.innerHTML = '';
|
||||||
this.domElement.appendChild(status);
|
if (this.displayMode === DisplayMode.Edit) {
|
||||||
|
this.pickerHost = document.createElement('div');
|
||||||
|
this.domElement.appendChild(this.pickerHost);
|
||||||
|
const picker: React.ReactElement<IOrganizationChartPeoplePickerProps> =
|
||||||
|
React.createElement(OrganizationChartPeoplePicker, {
|
||||||
|
description: strings.StartUserFieldDescription,
|
||||||
|
label: strings.StartUserFieldLabel,
|
||||||
|
loadingText: strings.PeoplePickerLoadingText,
|
||||||
|
noResultsText: strings.PeoplePickerNoResultsText,
|
||||||
|
onChange: (loginName: string, accountName: string): void =>
|
||||||
|
this.onStartUserChanged(loginName, accountName),
|
||||||
|
placeholder: strings.StartUserPlaceholder,
|
||||||
|
removeButtonAriaLabel: strings.PeoplePickerRemoveLabel,
|
||||||
|
searchPeople: (query: string): Promise<IOrganizationChartPersonSuggestion[]> =>
|
||||||
|
this.service.searchPeople(query),
|
||||||
|
selectedAccountName: normalizeAccount(this.properties.startUserAccountName),
|
||||||
|
selectedDisplayName: selectedDisplayName,
|
||||||
|
selectedLoginName: normalizeAccount(this.properties.startUser),
|
||||||
|
suggestionsHeaderText: strings.PeoplePickerSuggestionsLabel,
|
||||||
|
validationErrorText: strings.PeoplePickerValidationError,
|
||||||
|
validatePerson: (loginName: string, accountName: string): Promise<boolean> =>
|
||||||
|
this.service.validatePerson(loginName, accountName, normalizeCacheMinutes(this.properties.cacheMinutes))
|
||||||
|
});
|
||||||
|
ReactDom.render(picker, this.pickerHost);
|
||||||
|
}
|
||||||
|
this.contentHost = document.createElement('div');
|
||||||
|
this.domElement.appendChild(this.contentHost);
|
||||||
|
return this.contentHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSurface(): void {
|
||||||
|
if (this.pickerHost) { ReactDom.unmountComponentAtNode(this.pickerHost); }
|
||||||
|
if (this.contentHost) { ReactDom.unmountComponentAtNode(this.contentHost); }
|
||||||
|
this.pickerHost = undefined;
|
||||||
|
this.contentHost = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStartUserChanged(loginName: string, accountName: string): void {
|
||||||
|
const value: string = normalizeAccount(loginName);
|
||||||
|
const fallback: string = normalizeAccount(accountName);
|
||||||
|
if (value === normalizeAccount(this.properties.startUser) &&
|
||||||
|
fallback === normalizeAccount(this.properties.startUserAccountName)) { return; }
|
||||||
|
this.properties.startUser = value;
|
||||||
|
this.properties.startUserAccountName = fallback;
|
||||||
|
this.render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
color: "[theme:bodyText, default:#323130]";
|
color: "[theme:bodyText, default:#323130]";
|
||||||
font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
|
font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
|
||||||
}
|
}
|
||||||
|
.pickerPanel { background: "[theme:neutralLighterAlt, default:#faf9f8]"; border: 1px solid "[theme:neutralLight, default:#edebe9]"; margin-bottom: 16px; padding: 12px; }
|
||||||
|
.pickerLabel { color: "[theme:bodyText, default:#323130]"; display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.pickerDescription { color: "[theme:neutralSecondary, default:#605e5c]"; font-size: 12px; line-height: 17px; margin-top: 6px; }
|
||||||
|
.pickerError { color: "[theme:errorText, default:#a80000]"; font-size: 12px; line-height: 17px; margin-top: 6px; }
|
||||||
.header { align-items: baseline; display: flex; justify-content: space-between; margin-bottom: 12px; }
|
.header { align-items: baseline; display: flex; justify-content: space-between; margin-bottom: 12px; }
|
||||||
.heading { font-size: 24px; font-weight: 300; line-height: 32px; margin: 0; }
|
.heading { font-size: 24px; font-weight: 300; line-height: 32px; margin: 0; }
|
||||||
.count { color: "[theme:neutralSecondary, default:#605e5c]"; font-size: 12px; }
|
.count { color: "[theme:neutralSecondary, default:#605e5c]"; font-size: 12px; }
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
NormalPeoplePicker,
|
||||||
|
ValidationState
|
||||||
|
} from 'office-ui-fabric-react/lib/Pickers';
|
||||||
|
import { IPersonaProps } from 'office-ui-fabric-react/lib/Persona';
|
||||||
|
import { IOrganizationChartPersonSuggestion } from '../OrganizationChartTypes';
|
||||||
|
import styles from './OrganizationChart.module.scss';
|
||||||
|
|
||||||
|
export interface IOrganizationChartPickerItem extends IPersonaProps {
|
||||||
|
accountName: string;
|
||||||
|
key: string;
|
||||||
|
loginName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOrganizationChartPeoplePickerProps {
|
||||||
|
description: string;
|
||||||
|
label: string;
|
||||||
|
loadingText: string;
|
||||||
|
noResultsText: string;
|
||||||
|
onChange: (loginName: string, accountName: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
removeButtonAriaLabel: string;
|
||||||
|
searchPeople: (query: string) => Promise<IOrganizationChartPersonSuggestion[]>;
|
||||||
|
selectedDisplayName: string;
|
||||||
|
selectedAccountName: string;
|
||||||
|
selectedLoginName: string;
|
||||||
|
suggestionsHeaderText: string;
|
||||||
|
validationErrorText: string;
|
||||||
|
validatePerson: (loginName: string, accountName: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOrganizationChartPeoplePickerState {
|
||||||
|
validationError: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OrganizationChartPeoplePicker
|
||||||
|
extends React.Component<IOrganizationChartPeoplePickerProps, IOrganizationChartPeoplePickerState> {
|
||||||
|
|
||||||
|
public constructor(props: IOrganizationChartPeoplePickerProps) {
|
||||||
|
super(props);
|
||||||
|
this.resolveSuggestions = this.resolveSuggestions.bind(this);
|
||||||
|
this.onSelectionChanged = this.onSelectionChanged.bind(this);
|
||||||
|
this.onItemSelected = this.onItemSelected.bind(this);
|
||||||
|
this.getTextFromItem = this.getTextFromItem.bind(this);
|
||||||
|
this.state = { validationError: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): React.ReactElement<HTMLDivElement> {
|
||||||
|
const selectedItems: IOrganizationChartPickerItem[] = this.props.selectedLoginName ? [{
|
||||||
|
key: this.props.selectedLoginName,
|
||||||
|
accountName: this.props.selectedAccountName,
|
||||||
|
loginName: this.props.selectedLoginName,
|
||||||
|
primaryText: this.props.selectedDisplayName || this.props.selectedLoginName,
|
||||||
|
secondaryText: this.props.selectedLoginName
|
||||||
|
}] : [];
|
||||||
|
return (
|
||||||
|
<div className={styles.pickerPanel}>
|
||||||
|
<label className={styles.pickerLabel}>{this.props.label}</label>
|
||||||
|
<NormalPeoplePicker
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onResolveSuggestions={this.resolveSuggestions}
|
||||||
|
onChange={this.onSelectionChanged}
|
||||||
|
onItemSelected={this.onItemSelected}
|
||||||
|
getTextFromItem={this.getTextFromItem}
|
||||||
|
onValidateInput={(): ValidationState => ValidationState.invalid}
|
||||||
|
itemLimit={1}
|
||||||
|
inputProps={{
|
||||||
|
'aria-label': this.props.label,
|
||||||
|
placeholder: this.props.placeholder
|
||||||
|
}}
|
||||||
|
pickerSuggestionsProps={{
|
||||||
|
loadingText: this.props.loadingText,
|
||||||
|
noResultsFoundText: this.props.noResultsText,
|
||||||
|
suggestionsHeaderText: this.props.suggestionsHeaderText
|
||||||
|
}}
|
||||||
|
removeButtonAriaLabel={this.props.removeButtonAriaLabel}
|
||||||
|
/>
|
||||||
|
{this.state.validationError ?
|
||||||
|
<div className={styles.pickerError} role='alert'>{this.state.validationError}</div> : null}
|
||||||
|
<div className={styles.pickerDescription}>{this.props.description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSuggestions(filter: string): Promise<IOrganizationChartPickerItem[]> {
|
||||||
|
return this.props.searchPeople(filter).then(
|
||||||
|
(suggestions: IOrganizationChartPersonSuggestion[]): IOrganizationChartPickerItem[] =>
|
||||||
|
suggestions.map((suggestion: IOrganizationChartPersonSuggestion): IOrganizationChartPickerItem => ({
|
||||||
|
key: suggestion.loginName,
|
||||||
|
accountName: suggestion.accountName,
|
||||||
|
loginName: suggestion.loginName,
|
||||||
|
primaryText: suggestion.displayName,
|
||||||
|
secondaryText: suggestion.email || suggestion.accountName || suggestion.loginName
|
||||||
|
})),
|
||||||
|
(): IOrganizationChartPickerItem[] => []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSelectionChanged(items?: IOrganizationChartPickerItem[]): void {
|
||||||
|
this.setState({ validationError: '' });
|
||||||
|
this.props.onChange(
|
||||||
|
items && items.length ? items[0].loginName : '',
|
||||||
|
items && items.length ? items[0].accountName : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onItemSelected(item?: IOrganizationChartPickerItem): Promise<IOrganizationChartPickerItem> {
|
||||||
|
if (!item) { return Promise.resolve(undefined as IOrganizationChartPickerItem); }
|
||||||
|
this.setState({ validationError: '' });
|
||||||
|
return this.props.validatePerson(item.loginName, item.accountName).then(
|
||||||
|
(valid: boolean): IOrganizationChartPickerItem => {
|
||||||
|
if (valid) { return item; }
|
||||||
|
this.setState({ validationError: this.props.validationErrorText });
|
||||||
|
return undefined as IOrganizationChartPickerItem;
|
||||||
|
},
|
||||||
|
(): IOrganizationChartPickerItem => {
|
||||||
|
this.setState({ validationError: this.props.validationErrorText });
|
||||||
|
return undefined as IOrganizationChartPickerItem;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTextFromItem(item: IOrganizationChartPickerItem): string {
|
||||||
|
return item.primaryText || item.loginName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,13 @@ define([], function() {
|
|||||||
DisplayGroupName: 'Darstellung',
|
DisplayGroupName: 'Darstellung',
|
||||||
TitleFieldLabel: 'WebPart-Titel',
|
TitleFieldLabel: 'WebPart-Titel',
|
||||||
StartUserFieldLabel: 'Startperson / Manager',
|
StartUserFieldLabel: 'Startperson / Manager',
|
||||||
StartUserFieldDescription: 'Leer lassen, um beim aktuellen Benutzer zu starten. Zulässig sind Claims-Login, DOMAIN\\Benutzer oder UPN.',
|
StartUserFieldDescription: 'Einen geprüften SharePoint-Benutzer suchen und auswählen. Leer lassen, um beim aktuellen Benutzer zu starten.',
|
||||||
StartUserPlaceholder: 'DOMAIN\\Benutzer oder benutzer@contoso.local',
|
StartUserPlaceholder: 'Name oder E-Mail-Adresse eingeben',
|
||||||
|
PeoplePickerLoadingText: 'Personen werden gesucht...',
|
||||||
|
PeoplePickerNoResultsText: 'Keine passenden Personen gefunden.',
|
||||||
|
PeoplePickerRemoveLabel: 'Startperson entfernen',
|
||||||
|
PeoplePickerSuggestionsLabel: 'Vorgeschlagene Personen',
|
||||||
|
PeoplePickerValidationError: 'Für diese Person ist kein lesbares Profil im SharePoint User Profile Service vorhanden.',
|
||||||
MaxDepthFieldLabel: 'Maximale Ebenen nach unten',
|
MaxDepthFieldLabel: 'Maximale Ebenen nach unten',
|
||||||
MaxPeopleFieldLabel: 'Maximale Personenzahl',
|
MaxPeopleFieldLabel: 'Maximale Personenzahl',
|
||||||
CacheMinutesFieldLabel: 'Profilcache (Minuten, 0 = aus)',
|
CacheMinutesFieldLabel: 'Profilcache (Minuten, 0 = aus)',
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ define([], function() {
|
|||||||
DisplayGroupName: 'Display',
|
DisplayGroupName: 'Display',
|
||||||
TitleFieldLabel: 'Web part title',
|
TitleFieldLabel: 'Web part title',
|
||||||
StartUserFieldLabel: 'Starting person / manager',
|
StartUserFieldLabel: 'Starting person / manager',
|
||||||
StartUserFieldDescription: 'Leave empty to start with the current user. Claims login, DOMAIN\\user or UPN are supported.',
|
StartUserFieldDescription: 'Search for and select a verified SharePoint user. Leave empty to start with the current user.',
|
||||||
StartUserPlaceholder: 'DOMAIN\\user or user@contoso.local',
|
StartUserPlaceholder: 'Enter a name or email address',
|
||||||
|
PeoplePickerLoadingText: 'Searching for people...',
|
||||||
|
PeoplePickerNoResultsText: 'No matching people found.',
|
||||||
|
PeoplePickerRemoveLabel: 'Remove starting person',
|
||||||
|
PeoplePickerSuggestionsLabel: 'Suggested people',
|
||||||
|
PeoplePickerValidationError: 'This person has no readable profile in the SharePoint User Profile Service.',
|
||||||
MaxDepthFieldLabel: 'Maximum levels below',
|
MaxDepthFieldLabel: 'Maximum levels below',
|
||||||
MaxPeopleFieldLabel: 'Maximum number of people',
|
MaxPeopleFieldLabel: 'Maximum number of people',
|
||||||
CacheMinutesFieldLabel: 'Profile cache (minutes, 0 = off)',
|
CacheMinutesFieldLabel: 'Profile cache (minutes, 0 = off)',
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ declare interface IOrganizationChartWebPartStrings {
|
|||||||
StartUserFieldLabel: string;
|
StartUserFieldLabel: string;
|
||||||
StartUserFieldDescription: string;
|
StartUserFieldDescription: string;
|
||||||
StartUserPlaceholder: string;
|
StartUserPlaceholder: string;
|
||||||
|
PeoplePickerLoadingText: string;
|
||||||
|
PeoplePickerNoResultsText: string;
|
||||||
|
PeoplePickerRemoveLabel: string;
|
||||||
|
PeoplePickerSuggestionsLabel: string;
|
||||||
|
PeoplePickerValidationError: string;
|
||||||
MaxDepthFieldLabel: string;
|
MaxDepthFieldLabel: string;
|
||||||
MaxPeopleFieldLabel: string;
|
MaxPeopleFieldLabel: string;
|
||||||
CacheMinutesFieldLabel: string;
|
CacheMinutesFieldLabel: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ var manifest = json('src/webparts/organizationChart/OrganizationChartWebPart.man
|
|||||||
var service = read('src/webparts/organizationChart/OrganizationChartDataService.ts');
|
var service = read('src/webparts/organizationChart/OrganizationChartDataService.ts');
|
||||||
var webPart = read('src/webparts/organizationChart/OrganizationChartWebPart.ts');
|
var webPart = read('src/webparts/organizationChart/OrganizationChartWebPart.ts');
|
||||||
var component = read('src/webparts/organizationChart/components/OrganizationChart.tsx');
|
var component = read('src/webparts/organizationChart/components/OrganizationChart.tsx');
|
||||||
|
var picker = read('src/webparts/organizationChart/components/OrganizationChartPeoplePicker.tsx');
|
||||||
var styles = read('src/webparts/organizationChart/components/OrganizationChart.module.scss');
|
var styles = read('src/webparts/organizationChart/components/OrganizationChart.module.scss');
|
||||||
|
|
||||||
assert(packageJson.name === 'organization-chart-webpart', 'Package name is inconsistent.');
|
assert(packageJson.name === 'organization-chart-webpart', 'Package name is inconsistent.');
|
||||||
@@ -31,7 +32,13 @@ assert(service.indexOf('ClientPeoplePickerWebServiceInterface.clientPeoplePicker
|
|||||||
'Configured-account resolution through the SharePoint People Picker is missing.');
|
'Configured-account resolution through the SharePoint People Picker is missing.');
|
||||||
assert(service.indexOf('profile.directReports') >= 0 && service.indexOf('this.buildNode(childAccount, depth + 1') >= 0,
|
assert(service.indexOf('profile.directReports') >= 0 && service.indexOf('this.buildNode(childAccount, depth + 1') >= 0,
|
||||||
'Recursive downward organization loading is missing.');
|
'Recursive downward organization loading is missing.');
|
||||||
assert(webPart.indexOf("PropertyPaneTextField('startUser'") >= 0, 'Starting-person configuration 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(picker.indexOf('NormalPeoplePicker') >= 0 && picker.indexOf('ValidationState.invalid') >= 0,
|
||||||
|
'The on-canvas people picker must reject unverified free-text values.');
|
||||||
|
assert(picker.indexOf('validatePerson') >= 0 && service.indexOf('public validatePerson') >= 0,
|
||||||
|
'Selected people must be validated against the User Profile Service.');
|
||||||
assert(webPart.indexOf("PropertyPaneSlider('maxDepth'") >= 0 && webPart.indexOf("PropertyPaneSlider('maxPeople'") >= 0,
|
assert(webPart.indexOf("PropertyPaneSlider('maxDepth'") >= 0 && webPart.indexOf("PropertyPaneSlider('maxPeople'") >= 0,
|
||||||
'Tree safety limits are missing.');
|
'Tree safety limits are missing.');
|
||||||
assert(component.indexOf("role='tree'") >= 0 && component.indexOf("role='treeitem'") >= 0,
|
assert(component.indexOf("role='tree'") >= 0 && component.indexOf("role='treeitem'") >= 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user