322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
|
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
|
import {
|
|
normalizeAccount,
|
|
normalizeCacheMinutes,
|
|
normalizeMaxDepth,
|
|
normalizeMaxPeople,
|
|
normalizeProfileUrl,
|
|
profileProperty,
|
|
uniqueAccounts
|
|
} from './OrganizationChartCore';
|
|
import {
|
|
IOrganizationChartData,
|
|
IOrganizationChartPersonSuggestion,
|
|
IOrganizationNode,
|
|
IUserProfile,
|
|
IUserProfileProperty
|
|
} from './OrganizationChartTypes';
|
|
|
|
interface IProfileCacheEntry {
|
|
expires: number;
|
|
profile: IUserProfile;
|
|
}
|
|
|
|
interface ITreeLoadState {
|
|
count: number;
|
|
maximum: number;
|
|
maximumDepth: number;
|
|
truncated: boolean;
|
|
visited: { [account: string]: boolean };
|
|
}
|
|
|
|
interface IPeoplePickerEntityData {
|
|
AccountName?: string;
|
|
Email?: string;
|
|
}
|
|
|
|
interface IPeoplePickerEntity {
|
|
DisplayText?: string;
|
|
EntityData?: IPeoplePickerEntityData;
|
|
IsResolved?: boolean;
|
|
Key?: string;
|
|
}
|
|
|
|
export class OrganizationChartDataService {
|
|
private static cache: { [account: string]: IProfileCacheEntry } = {};
|
|
|
|
public constructor(private context: WebPartContext) { }
|
|
|
|
public getOrganization(
|
|
accountName: string,
|
|
maxDepth: number,
|
|
maxPeople: number,
|
|
cacheMinutes: number,
|
|
alternateAccountName?: string
|
|
): Promise<IOrganizationChartData> {
|
|
const account: string = normalizeAccount(accountName);
|
|
const state: ITreeLoadState = {
|
|
count: 0,
|
|
maximum: normalizeMaxPeople(maxPeople),
|
|
maximumDepth: normalizeMaxDepth(maxDepth),
|
|
truncated: false,
|
|
visited: {}
|
|
};
|
|
const rootRequest: Promise<IUserProfile> = account
|
|
? this.getConfiguredProfile(account, cacheMinutes, alternateAccountName)
|
|
: this.getMyProfile(cacheMinutes);
|
|
return rootRequest
|
|
.then((profile: IUserProfile): Promise<IOrganizationNode> =>
|
|
this.buildProfileNode(profile, 0, state, cacheMinutes))
|
|
.then((root: IOrganizationNode): IOrganizationChartData => ({
|
|
root: root,
|
|
peopleCount: state.count,
|
|
truncated: state.truncated
|
|
}));
|
|
}
|
|
|
|
public getMyProfile(cacheMinutes: number): Promise<IUserProfile> {
|
|
const cacheKey: string = '__current_user__';
|
|
const cached: IProfileCacheEntry = OrganizationChartDataService.cache[cacheKey];
|
|
if (cached && cached.expires > new Date().getTime()) { return Promise.resolve(cached.profile); }
|
|
return this.requestProfile(
|
|
this.webUrl + '/_api/SP.UserProfiles.PeopleManager/GetMyProperties',
|
|
cacheKey,
|
|
cacheMinutes
|
|
);
|
|
}
|
|
|
|
public getProfile(accountName: string, cacheMinutes: number): Promise<IUserProfile> {
|
|
const account: string = normalizeAccount(accountName);
|
|
const key: string = account.toLowerCase();
|
|
const cached: IProfileCacheEntry = OrganizationChartDataService.cache[key];
|
|
if (cached && cached.expires > new Date().getTime()) { return Promise.resolve(cached.profile); }
|
|
const escaped: string = account.replace(/'/g, "''");
|
|
const url: string = this.webUrl +
|
|
"/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor(accountName=@v)?@v='" + encodeURIComponent(escaped) + "'";
|
|
return this.requestProfile(url, key, cacheMinutes);
|
|
}
|
|
|
|
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)
|
|
.catch((): string[] => [accountName])
|
|
.then((accounts: string[]): Promise<IUserProfile> => {
|
|
const candidates: string[] = uniqueAccounts(accounts.concat([alternateAccountName]), '', accounts.length + 1);
|
|
return this.getFirstAvailableProfile(candidates, cacheMinutes, 0);
|
|
});
|
|
}
|
|
|
|
private getFirstAvailableProfile(
|
|
accounts: string[],
|
|
cacheMinutes: number,
|
|
index: number,
|
|
previousError?: Error
|
|
): Promise<IUserProfile> {
|
|
if (index >= accounts.length) {
|
|
return Promise.reject(previousError || new Error('The configured user could not be resolved.'));
|
|
}
|
|
return this.getProfile(accounts[index], cacheMinutes)
|
|
.catch((error: Error): Promise<IUserProfile> =>
|
|
this.getFirstAvailableProfile(accounts, cacheMinutes, index + 1, error));
|
|
}
|
|
|
|
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 +
|
|
'/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser';
|
|
const body: string = JSON.stringify({
|
|
queryParams: {
|
|
__metadata: { type: 'SP.UI.ApplicationPages.ClientPeoplePickerQueryParameters' },
|
|
AllowEmailAddresses: true,
|
|
AllowMultipleEntities: false,
|
|
AllUrlZones: false,
|
|
MaximumEntitySuggestions: 10,
|
|
PrincipalSource: 15,
|
|
PrincipalType: 1,
|
|
QueryString: accountName
|
|
}
|
|
});
|
|
const headers: any = { // tslint:disable-line:no-any
|
|
'Accept': 'application/json;odata=verbose',
|
|
'Content-Type': 'application/json;odata=verbose',
|
|
'OData-Version': '3.0'
|
|
};
|
|
return this.context.spHttpClient.post(url, SPHttpClient.configurations.v1, { headers: headers, body: body })
|
|
.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('People Picker request failed (' + response.status + '): ' + text)));
|
|
}).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 serialized: string = String(
|
|
data && data.ClientPeoplePickerSearchUser !== undefined
|
|
? data.ClientPeoplePickerSearchUser
|
|
: (data && data.value !== undefined ? data.value : '[]')
|
|
);
|
|
try { return JSON.parse(serialized) as IPeoplePickerEntity[]; } catch (error) { return []; }
|
|
});
|
|
}
|
|
|
|
private requestProfile(url: string, cacheKey: string, cacheMinutes: number): Promise<IUserProfile> {
|
|
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers })
|
|
.then((response: SPHttpClientResponse): Promise<any> => { // tslint:disable-line:no-any
|
|
if (response.ok) { return response.json(); }
|
|
return response.text().then((text: string): Promise<any> => // tslint:disable-line:no-any
|
|
Promise.reject(new Error('User profile request failed (' + response.status + '): ' + text)));
|
|
}).then((payload: any): IUserProfile => { // tslint:disable-line:no-any
|
|
const raw: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
|
|
const profile: IUserProfile = this.mapProfile(raw || {});
|
|
const minutes: number = normalizeCacheMinutes(cacheMinutes);
|
|
if (minutes > 0) {
|
|
OrganizationChartDataService.cache[cacheKey] = {
|
|
profile: profile,
|
|
expires: new Date().getTime() + minutes * 60000
|
|
};
|
|
if (profile.accountName) {
|
|
OrganizationChartDataService.cache[profile.accountName.toLowerCase()] =
|
|
OrganizationChartDataService.cache[cacheKey];
|
|
}
|
|
}
|
|
return profile;
|
|
});
|
|
}
|
|
|
|
private buildNode(
|
|
accountName: string,
|
|
depth: number,
|
|
state: ITreeLoadState,
|
|
cacheMinutes: number
|
|
): Promise<IOrganizationNode> {
|
|
const account: string = normalizeAccount(accountName);
|
|
const key: string = account.toLowerCase();
|
|
if (!account || state.visited[key] || state.count >= state.maximum) {
|
|
if (state.count >= state.maximum) { state.truncated = true; }
|
|
return Promise.resolve(undefined as IOrganizationNode);
|
|
}
|
|
return this.getProfile(account, cacheMinutes).then((profile: IUserProfile): Promise<IOrganizationNode> =>
|
|
this.buildProfileNode(profile, depth, state, cacheMinutes));
|
|
}
|
|
|
|
private buildProfileNode(
|
|
profile: IUserProfile,
|
|
depth: number,
|
|
state: ITreeLoadState,
|
|
cacheMinutes: number
|
|
): Promise<IOrganizationNode> {
|
|
const key: string = normalizeAccount(profile.accountName).toLowerCase();
|
|
if (!key || state.visited[key] || state.count >= state.maximum) {
|
|
if (state.count >= state.maximum) { state.truncated = true; }
|
|
return Promise.resolve(undefined as IOrganizationNode);
|
|
}
|
|
state.visited[key] = true;
|
|
state.count++;
|
|
const node: IOrganizationNode = { profile: profile, children: [] };
|
|
if (depth >= state.maximumDepth) {
|
|
if (profile.directReports.length) { state.truncated = true; }
|
|
return Promise.resolve(node);
|
|
}
|
|
const accounts: string[] = uniqueAccounts(profile.directReports, profile.accountName, state.maximum);
|
|
const requests: Array<Promise<IOrganizationNode>> = accounts.map(
|
|
(childAccount: string): Promise<IOrganizationNode> =>
|
|
this.buildNode(childAccount, depth + 1, state, cacheMinutes)
|
|
.catch((): IOrganizationNode => undefined as IOrganizationNode)
|
|
);
|
|
return Promise.all(requests).then((children: IOrganizationNode[]): IOrganizationNode => {
|
|
node.children = children.filter((child: IOrganizationNode): boolean => !!child)
|
|
.sort((left: IOrganizationNode, right: IOrganizationNode): number =>
|
|
left.profile.displayName.localeCompare(right.profile.displayName));
|
|
return node;
|
|
});
|
|
}
|
|
|
|
private mapProfile(raw: any): IUserProfile { // tslint:disable-line:no-any
|
|
const properties: IUserProfileProperty[] = this.results(raw.UserProfileProperties)
|
|
.map((entry: any): IUserProfileProperty => ({ // tslint:disable-line:no-any
|
|
Key: String(entry.Key || ''),
|
|
Value: String(entry.Value || '')
|
|
}));
|
|
const account: string = String(raw.AccountName || profileProperty(properties, 'AccountName') || '');
|
|
let pictureUrl: string = normalizeProfileUrl(String(raw.PictureUrl || ''), this.webUrl);
|
|
if (!pictureUrl && account) {
|
|
pictureUrl = this.webUrl + '/_layouts/15/userphoto.aspx?size=L&accountname=' + encodeURIComponent(account);
|
|
}
|
|
return {
|
|
accountName: account,
|
|
displayName: String(raw.DisplayName || profileProperty(properties, 'PreferredName') || account),
|
|
email: String(raw.Email || profileProperty(properties, 'WorkEmail') || ''),
|
|
title: String(raw.Title || profileProperty(properties, 'SPS-JobTitle') || ''),
|
|
department: profileProperty(properties, 'Department'),
|
|
office: profileProperty(properties, 'Office'),
|
|
workPhone: profileProperty(properties, 'WorkPhone'),
|
|
cellPhone: profileProperty(properties, 'CellPhone'),
|
|
location: profileProperty(properties, 'SPS-Location'),
|
|
managerAccount: profileProperty(properties, 'Manager'),
|
|
pictureUrl: pictureUrl,
|
|
profileUrl: normalizeProfileUrl(String(raw.UserUrl || raw.PersonalUrl || ''), this.webUrl),
|
|
directReports: this.stringResults(raw.DirectReports),
|
|
extendedManagers: this.stringResults(raw.ExtendedManagers)
|
|
};
|
|
}
|
|
|
|
private stringResults(value: any): string[] { // tslint:disable-line:no-any
|
|
return this.results(value).map((entry: any): string => String(entry || '')); // tslint:disable-line:no-any
|
|
}
|
|
private results(value: any): any[] { // tslint:disable-line:no-any
|
|
if (!value) { return []; }
|
|
if (Array.isArray(value.results)) { return value.results; }
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); }
|
|
private get headers(): any { // tslint:disable-line:no-any
|
|
return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' };
|
|
}
|
|
}
|