Skip to content

Commit 4666fa1

Browse files
sregan1claude
andcommitted
Add configurable data source (Graph API / SP Search / Auto)
Root cause of missing direct reports in production: the primary data source is SharePoint People Search, which has hours-to-days of indexing delay. New users added to Azure AD do not appear until the index catches up, and the supplemental Graph directReports call never fires for managers who aren't already in the search index. Fix: add a dataSource admin property (Auto / Graph API / SharePoint Search) - Auto (new default): tries Graph API first; falls back to SP Search with a console warning if Graph is unavailable or returns 0 users - Graph API: calls /users?$expand=manager with full pagination — reads live AAD data with no indexing delay; new users appear immediately - SharePoint Search: preserves previous behavior for tenants that prefer it GraphService changes: - New _dataSource field on constructor (4th param, default 'auto') - _loadUsers() dispatches to _fetchAllUsersFromGraph() or the existing SP Search path based on _dataSource - New _fetchAllUsersFromGraph(): paginates /users?$expand=manager, maps manager.userPrincipalName into _pendingManagerIds (same key format as SP Search path so _buildMaps works identically), pre-populates _photoCache with the /_layouts/15/userphoto.aspx URL pattern - If dataSource='graph' and Graph client is absent, throws a descriptive error instead of silently returning empty results - DataSource type exported for consumers Property pane: new 'Data Source' group with a ChoiceGroup above the Org Chart settings group. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f9cee47 commit 4666fa1

3 files changed

Lines changed: 136 additions & 5 deletions

File tree

src/services/GraphService.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ const SELECT_PROPS = 'AccountName,DisplayName,PreferredName,JobTitle,Departmen
3030
'WorkEmail,WorkPhone,MobilePhone,OfficeNumber,PictureURL,Manager';
3131
const BATCH_SIZE = 500;
3232

33+
export type DataSource = 'auto' | 'graph' | 'search';
34+
3335
export class GraphService {
3436
private _client: SPHttpClient;
3537
private _graphClient: MSGraphClientV3 | undefined;
3638
private _webUrl: string;
39+
private _dataSource: DataSource;
3740
private _photoCache: Map<string, string | null> = new Map();
3841
private _allUsersCache: IGraphUser[] | null = null;
3942
private _childrenMap: Map<string, IGraphUser[]> = new Map();
@@ -45,10 +48,11 @@ export class GraphService {
4548
private _presenceExpiry = 0;
4649
private readonly _PRESENCE_TTL = 60_000;
4750

48-
constructor(client: SPHttpClient, webUrl: string, graphClient?: MSGraphClientV3) {
51+
constructor(client: SPHttpClient, webUrl: string, graphClient?: MSGraphClientV3, dataSource: DataSource = 'auto') {
4952
this._client = client;
5053
this._webUrl = webUrl.replace(/\/$/, '');
5154
this._graphClient = graphClient;
55+
this._dataSource = dataSource;
5256
}
5357

5458
/* ── Public API ──────────────────────────────────────────────────── */
@@ -57,8 +61,7 @@ export class GraphService {
5761
if (this._allUsersCache) return Promise.resolve(this._allUsersCache);
5862
if (this._loadingPromise) return this._loadingPromise;
5963

60-
this._loadingPromise = this._fetchAllUsers()
61-
.then(users => this._supplementUnlicensedReports(users))
64+
this._loadingPromise = this._loadUsers()
6265
.then(users => {
6366
this._allUsersCache = users;
6467
this._buildMaps(users);
@@ -73,6 +76,35 @@ export class GraphService {
7376
return this._loadingPromise;
7477
}
7578

79+
private async _loadUsers(): Promise<IGraphUser[]> {
80+
const useGraph = this._dataSource === 'graph' ||
81+
(this._dataSource === 'auto' && !!this._graphClient);
82+
83+
if (this._dataSource === 'graph' && !this._graphClient) {
84+
throw new Error(
85+
'Graph API data source selected but Microsoft Graph permissions have not been granted. ' +
86+
'Please approve the app permissions in the SharePoint App Catalog, or switch the Data Source setting to "SharePoint Search".'
87+
);
88+
}
89+
90+
if (useGraph) {
91+
try {
92+
const users = await this._fetchAllUsersFromGraph();
93+
if (users.length === 0) throw new Error('Graph API returned 0 users');
94+
return users;
95+
} catch (err) {
96+
if (this._dataSource === 'graph') throw err;
97+
// 'auto' mode: fall back to SharePoint Search
98+
console.warn('[SmartOrgChart] Graph API unavailable, falling back to SharePoint Search:', err);
99+
this._pendingManagerIds.clear();
100+
}
101+
}
102+
103+
// SharePoint Search path (primary or fallback)
104+
const users = await this._fetchAllUsers();
105+
return this._supplementUnlicensedReports(users);
106+
}
107+
76108
public async getUserPhoto(userId: string): Promise<string | null> {
77109
const key = userId.toLowerCase();
78110
if (this._photoCache.has(key)) return this._photoCache.get(key) ?? null;
@@ -289,6 +321,72 @@ export class GraphService {
289321
: users;
290322
}
291323

324+
private async _fetchAllUsersFromGraph(): Promise<IGraphUser[]> {
325+
if (!this._graphClient) throw new Error('Graph client not available');
326+
327+
const users: IGraphUser[] = [];
328+
const SELECT = 'id,displayName,mail,userPrincipalName,jobTitle,department,officeLocation,mobilePhone,businessPhones';
329+
let url: string | null =
330+
`/users?$select=${SELECT}&$expand=manager($select=id,userPrincipalName,mail)&$top=999`;
331+
332+
while (url) {
333+
const req = this._graphClient.api(url);
334+
if (!url.startsWith('https://')) req.version('v1.0');
335+
const response = await req.get();
336+
const items: Array<{
337+
id: string;
338+
displayName: string;
339+
mail: string;
340+
userPrincipalName: string;
341+
jobTitle: string;
342+
department: string;
343+
officeLocation: string;
344+
mobilePhone: string;
345+
businessPhones: string[];
346+
manager?: { id: string; userPrincipalName: string; mail: string };
347+
}> = response?.value || [];
348+
349+
for (const item of items) {
350+
const upn = (item.userPrincipalName || '').toLowerCase();
351+
const mail = (item.mail || '').toLowerCase();
352+
const id = upn || mail;
353+
if (!id || !item.displayName) continue;
354+
355+
// Pre-populate photo cache with SharePoint profile photo URL
356+
const photoEmail = mail || upn;
357+
const photoUrl = photoEmail
358+
? `${this._webUrl}/_layouts/15/userphoto.aspx?size=L&accountname=${encodeURIComponent(photoEmail)}`
359+
: null;
360+
this._photoCache.set(id, photoUrl);
361+
362+
// Store AAD object ID for presence lookups
363+
if (item.id) this._upnToObjectId.set(id, item.id);
364+
365+
// Manager relationship — prefer UPN, fall back to mail
366+
const mgrUpn = (item.manager?.userPrincipalName || '').toLowerCase();
367+
const mgrMail = (item.manager?.mail || '').toLowerCase();
368+
const mgrId = mgrUpn || mgrMail;
369+
if (mgrId) this._pendingManagerIds.set(id, mgrId);
370+
371+
users.push({
372+
id,
373+
displayName: item.displayName,
374+
mail: mail,
375+
jobTitle: item.jobTitle || '',
376+
mobilePhone: item.mobilePhone || '',
377+
businessPhones: item.businessPhones || [],
378+
department: item.department || '',
379+
officeLocation: item.officeLocation || '',
380+
userPrincipalName: upn,
381+
});
382+
}
383+
384+
url = response?.['@odata.nextLink'] || null;
385+
}
386+
387+
return users.sort((a, b) => (a.displayName || '').localeCompare(b.displayName || ''));
388+
}
389+
292390
private async _fetchAllUsers(): Promise<IGraphUser[]> {
293391
const users: IGraphUser[] = [];
294392
let startRow = 0;

src/webparts/smartOrgChart/SmartOrgChartWebPart.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface ISmartOrgChartWebPartProps {
3232
enableStats: boolean;
3333
enableDeptFilter: boolean;
3434
enableUserFilter: boolean;
35+
// Data
36+
dataSource: 'auto' | 'graph' | 'search';
3537
}
3638

3739
export default class SmartOrgChartWebPart extends BaseClientSideWebPart<ISmartOrgChartWebPartProps> {
@@ -49,6 +51,8 @@ export default class SmartOrgChartWebPart extends BaseClientSideWebPart<ISmartOr
4951
// Branding defaults
5052
if (p.companyName === undefined) p.companyName = '';
5153
if (p.logoUrl === undefined) p.logoUrl = '';
54+
// Data source default
55+
if (p.dataSource === undefined) p.dataSource = 'auto';
5256
return super.onInit();
5357
}
5458

@@ -69,6 +73,7 @@ export default class SmartOrgChartWebPart extends BaseClientSideWebPart<ISmartOr
6973
enableStats: this.properties.enableStats !== false,
7074
enableDeptFilter: this.properties.enableDeptFilter !== false,
7175
enableUserFilter: this.properties.enableUserFilter !== false,
76+
dataSource: this.properties.dataSource || 'auto',
7277
onSettingsSaved: (newProps: Partial<ISmartOrgChartWebPartProps>) => {
7378
Object.assign(this.properties, newProps);
7479
}
@@ -146,6 +151,34 @@ export default class SmartOrgChartWebPart extends BaseClientSideWebPart<ISmartOr
146151
}),
147152
]
148153
},
154+
{
155+
groupName: 'Data Source',
156+
groupFields: [
157+
PropertyPaneChoiceGroup('dataSource', {
158+
label: 'Where to load user & org data from',
159+
options: [
160+
{
161+
key: 'auto',
162+
text: 'Auto — Graph API, fall back to SharePoint Search',
163+
iconProps: { officeFabricIconFontName: 'AutoEnhanceOn' }
164+
},
165+
{
166+
key: 'graph',
167+
text: 'Graph API — live Azure AD data (no indexing delay)',
168+
iconProps: { officeFabricIconFontName: 'AzureLogo' }
169+
},
170+
{
171+
key: 'search',
172+
text: 'SharePoint Search — legacy behavior',
173+
iconProps: { officeFabricIconFontName: 'Search' }
174+
}
175+
]
176+
}),
177+
PropertyPaneLabel('dataSource', {
178+
text: 'Graph API is recommended. It reads directly from Azure Active Directory so new users and manager changes appear immediately. Requires Microsoft Graph permissions to be approved in the SharePoint App Catalog.'
179+
})
180+
]
181+
},
149182
{
150183
groupName: 'Org Chart',
151184
groupFields: [

src/webparts/smartOrgChart/components/SmartOrgChart.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export class SmartOrgChart extends React.Component<ISmartOrgChartProps, ISmartOr
9191
}
9292

9393
public async componentDidUpdate(prev: ISmartOrgChartProps): Promise<void> {
94-
if (prev.useDemoData !== this.props.useDemoData) {
94+
if (prev.useDemoData !== this.props.useDemoData || prev.dataSource !== this.props.dataSource) {
9595
await this._initGraphService();
9696
}
9797
}
@@ -112,7 +112,7 @@ export class SmartOrgChart extends React.Component<ISmartOrgChartProps, ISmartOr
112112
} catch {
113113
// Graph client unavailable — fall back to SP Search only
114114
}
115-
this.setState({ graphService: new GraphService(spHttpClient, pageContext.web.absoluteUrl, graphClient) });
115+
this.setState({ graphService: new GraphService(spHttpClient, pageContext.web.absoluteUrl, graphClient, this.props.dataSource || 'auto') });
116116
}
117117

118118
private _setMockSize = (size: MockCompanySize): void => {

0 commit comments

Comments
 (0)