Files
Organization-Chart/src/webparts/organizationChart/OrganizationChartDataService.ts
T

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' };
}
}