Skip to content

Commit 36d7493

Browse files
authored
feat(sidebar): list permitted projects with persona-priority default (#643)
* feat(sidebar): list permitted projects with persona-priority default Drop the in-app persona filter on the foundation/project lens dropdown so the sidebar lists every project the user can access via the query service. Reintroduce default selection by persona priority: foundation lens prefers ED, then board member; project lens prefers maintainer, then contributor; both fall back to the first item. LFXV2-1650 Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(sidebar): pre-fetch priority project so it survives pagination When the highest-priority persona match (e.g. an ED's foundation or a maintainer's project) falls outside the first page, inject its UID via the existing selected_uid mechanism so it is guaranteed to appear and gets selected. The picker now matches in persona-array order rather than displayed order, keeping selection deterministic across both foundation and project lenses. LFXV2-1650 Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent eccb1e5 commit 36d7493

6 files changed

Lines changed: 48 additions & 174 deletions

File tree

apps/lfx-one/src/app/shared/services/navigation.service.ts

Lines changed: 27 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { HttpClient, HttpParams } from '@angular/common/http';
55
import { computed, inject, Injectable, Signal, signal, WritableSignal } from '@angular/core';
66
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
77
import { Router } from '@angular/router';
8-
import { BOARD_SCOPED_PERSONA_PRIORITY, LENS_DEFAULT_ROUTES, NAV_SEARCH_DEBOUNCE_MS } from '@lfx-one/shared/constants';
9-
import { LensItem, LensItemsResponse, LensPage, LensState, NavLens, TaggedLensPage } from '@lfx-one/shared/interfaces';
8+
import { BOARD_SCOPED_PERSONA_PRIORITY, LENS_DEFAULT_ROUTES, NAV_SEARCH_DEBOUNCE_MS, PROJECT_SCOPED_PERSONA_PRIORITY } from '@lfx-one/shared/constants';
9+
import { LensItem, LensItemsResponse, LensPage, LensState, NavLens, PersonaType, TaggedLensPage } from '@lfx-one/shared/interfaces';
1010
import { lensItemToProjectContext } from '@lfx-one/shared/utils';
1111
import { MessageService } from 'primeng/api';
1212
import { catchError, debounceTime, distinctUntilChanged, EMPTY, filter, map, merge, Observable, of, scan, skip, Subject, switchMap, tap } from 'rxjs';
@@ -61,14 +61,6 @@ export class NavigationService {
6161
return this.getState(lens).searchTerm;
6262
}
6363

64-
public bypassActive(lens: NavLens): Signal<boolean> {
65-
return this.getState(lens).bypassActive;
66-
}
67-
68-
public personaFetchFailed(lens: NavLens): Signal<boolean> {
69-
return this.getState(lens).personaFetchFailed;
70-
}
71-
7264
public setSearchTerm(lens: NavLens, term: string): void {
7365
this.getState(lens).searchTerm.set(term);
7466
}
@@ -109,7 +101,8 @@ export class NavigationService {
109101
return;
110102
}
111103

112-
const defaultItem = lens === 'foundation' ? this.pickFoundationByPersonaPriority(page.items) : page.items[0];
104+
const priority = lens === 'foundation' ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY;
105+
const defaultItem = this.pickItemByPersonaPriority(page.items, priority);
113106
const context = lensItemToProjectContext(defaultItem);
114107
if (lens === 'foundation') {
115108
this.projectContextService.setFoundation(context);
@@ -118,21 +111,32 @@ export class NavigationService {
118111
}
119112
}
120113

121-
/** Prefer a foundation the user is ED of, then Board Member of; fall back to the first item. */
122-
private pickFoundationByPersonaPriority(items: LensItem[]): LensItem {
114+
/** Prefer items where the user holds an in-priority persona, in persona-array order; fall back to the first item. */
115+
private pickItemByPersonaPriority(items: LensItem[], priority: readonly PersonaType[]): LensItem {
123116
const personaProjects = this.personaService.personaProjects();
124-
for (const persona of BOARD_SCOPED_PERSONA_PRIORITY) {
125-
const personaUids = new Set((personaProjects[persona] ?? []).map((p) => p.projectUid));
126-
if (personaUids.size === 0) continue;
127-
const match = items.find((item) => personaUids.has(item.uid));
128-
if (match) return match;
117+
for (const persona of priority) {
118+
const projects = personaProjects[persona] ?? [];
119+
for (const project of projects) {
120+
const match = items.find((item) => item.uid === project.projectUid);
121+
if (match) return match;
122+
}
129123
}
130124
return items[0];
131125
}
132126

127+
/** First project UID of the highest-priority persona the user holds for this lens, or null. */
128+
private getPriorityUid(lens: NavLens): string | null {
129+
const priority = lens === 'foundation' ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY;
130+
const personaProjects = this.personaService.personaProjects();
131+
for (const persona of priority) {
132+
const projects = personaProjects[persona] ?? [];
133+
if (projects.length > 0) return projects[0].projectUid;
134+
}
135+
return null;
136+
}
137+
133138
private handleEmptyLensResponse(lens: NavLens, page: LensPage): void {
134-
const upstreamFailure = page.upstreamFailed || page.personaFetchFailed;
135-
const toast = upstreamFailure
139+
const toast = page.upstreamFailed
136140
? { severity: 'error', summary: 'Unable to load', detail: 'We were unable to load your data. Please try again in a moment.' }
137141
: {
138142
severity: 'info',
@@ -150,26 +154,12 @@ export class NavigationService {
150154
const loading = signal<boolean>(false);
151155
const loaded = signal<boolean>(false);
152156
const nextPageToken = signal<string | null>(null);
153-
const bypassActive = signal<boolean>(false);
154-
const personaFetchFailed = signal<boolean>(false);
155157
const pendingDefaultSelection = signal<boolean>(false);
156158
const generation = signal<number>(0);
157159
const loadMore$ = new Subject<string>();
158160
const reload$ = new Subject<void>();
159161

160-
const items = this.initItems(
161-
lens,
162-
searchTerm,
163-
loading,
164-
loaded,
165-
nextPageToken,
166-
bypassActive,
167-
personaFetchFailed,
168-
pendingDefaultSelection,
169-
generation,
170-
loadMore$,
171-
reload$
172-
);
162+
const items = this.initItems(lens, searchTerm, loading, loaded, nextPageToken, pendingDefaultSelection, generation, loadMore$, reload$);
173163
const hasMore = computed(() => nextPageToken() !== null);
174164

175165
return {
@@ -179,8 +169,6 @@ export class NavigationService {
179169
loaded,
180170
nextPageToken,
181171
hasMore,
182-
bypassActive,
183-
personaFetchFailed,
184172
pendingDefaultSelection,
185173
generation,
186174
loadMore$,
@@ -194,8 +182,6 @@ export class NavigationService {
194182
loading: WritableSignal<boolean>,
195183
loaded: WritableSignal<boolean>,
196184
nextPageToken: WritableSignal<string | null>,
197-
bypassActive: WritableSignal<boolean>,
198-
personaFetchFailed: WritableSignal<boolean>,
199185
pendingDefaultSelection: WritableSignal<boolean>,
200186
generation: WritableSignal<number>,
201187
loadMore$: Subject<string>,
@@ -234,8 +220,6 @@ export class NavigationService {
234220
map(({ page }) => page),
235221
tap((page) => {
236222
nextPageToken.set(page.nextPageToken);
237-
bypassActive.set(page.bypassActive);
238-
personaFetchFailed.set(page.personaFetchFailed);
239223
loaded.set(true);
240224
if (page.reset && pendingDefaultSelection()) {
241225
pendingDefaultSelection.set(false);
@@ -277,7 +261,7 @@ export class NavigationService {
277261
clearLoadingIfActive();
278262
if (reset) {
279263
return of({
280-
page: { items: [], nextPageToken: null, bypassActive: false, personaFetchFailed: false, upstreamFailed: true, reset: true },
264+
page: { items: [], nextPageToken: null, upstreamFailed: true, reset: true },
281265
generation,
282266
});
283267
}
@@ -302,15 +286,13 @@ export class NavigationService {
302286

303287
private getSelectedUidForLens(lens: NavLens): string | null {
304288
const context = lens === 'foundation' ? this.projectContextService.selectedFoundation() : this.projectContextService.selectedProject();
305-
return context?.uid ?? null;
289+
return context?.uid ?? this.getPriorityUid(lens);
306290
}
307291

308292
private toLensPage(response: LensItemsResponse, reset: boolean): LensPage {
309293
return {
310294
items: response.items,
311295
nextPageToken: response.next_page_token,
312-
bypassActive: response.bypass_active,
313-
personaFetchFailed: response.persona_fetch_failed,
314296
upstreamFailed: response.upstream_failed,
315297
reset,
316298
};

apps/lfx-one/src/server/controllers/navigation.controller.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ export class NavigationController {
4444
logger.success(req, 'get_lens_items', startTime, {
4545
lens: result.lens,
4646
item_count: result.items.length,
47-
bypass_active: result.bypass_active,
48-
persona_fetch_failed: result.persona_fetch_failed,
4947
});
5048

5149
res.json(result);

apps/lfx-one/src/server/services/navigation.service.ts

Lines changed: 17 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,18 @@
11
// Copyright The Linux Foundation and each contributor to LFX.
22
// SPDX-License-Identifier: MIT
33

4-
import { LENS_PERSONA_MAP, NAV_MAX_UPSTREAM_ITERATIONS, NAV_MIN_ITEMS_PER_RESPONSE } from '@lfx-one/shared/constants';
54
import { ProjectFunding, ProjectStage } from '@lfx-one/shared/enums';
6-
import {
7-
EnrichedPersonaProject,
8-
GetLensItemsParams,
9-
LensItem,
10-
LensItemsQuery,
11-
LensItemsResponse,
12-
NavLens,
13-
PersonaApiResponse,
14-
PersonaType,
15-
Project,
16-
QueryServiceResponse,
17-
} from '@lfx-one/shared/interfaces';
5+
import { GetLensItemsParams, LensItem, LensItemsQuery, LensItemsResponse, NavLens, Project, QueryServiceResponse } from '@lfx-one/shared/interfaces';
186
import { computeIsFoundation } from '@lfx-one/shared/utils';
197
import { Request } from 'express';
208

21-
import { personaDetectionService } from '../utils/persona-helper';
229
import { logger } from './logger.service';
2310
import { MicroserviceProxyService } from './microservice-proxy.service';
2411

2512
/** Stages eligible for the project lens — Active plus supported pre-launch formation stages. */
2613
const PROJECT_LENS_ALLOWED_STAGES = new Set<string>([ProjectStage.Active, ProjectStage.FormationEngaged, ProjectStage.FormationExploratory]);
2714

28-
/** Powers the foundation/project lens dropdown. Root writers bypass the persona filter. */
15+
/** Powers the foundation/project lens dropdown. Access is gated entirely by the user's bearer token via the query service. */
2916
export class NavigationService {
3017
private readonly microserviceProxy: MicroserviceProxyService;
3118

@@ -36,116 +23,47 @@ export class NavigationService {
3623
public async getLensItems(req: Request, params: GetLensItemsParams): Promise<LensItemsResponse> {
3724
const { lens, pageToken, name, selectedUid } = params;
3825

39-
// Parallel: root-writer check + first upstream page. Deferring persona fetch saves the
40-
// NATS roundtrip for admins (the hot path).
41-
const [bypassActive, firstPage] = await Promise.all([personaDetectionService.checkRootWriter(req), this.fetchUpstreamPage(req, lens, pageToken, name)]);
26+
const firstPage = await this.fetchUpstreamPage(req, lens, pageToken, name);
4227

43-
let persona: PersonaApiResponse | null = null;
44-
let personaFetchFailed = false;
45-
if (!bypassActive) {
46-
const personaResult = await this.fetchPersona(req);
47-
persona = personaResult.persona;
48-
personaFetchFailed = personaResult.failed;
49-
}
50-
51-
// Fail closed: a persona-fetch failure must not fall through to unfiltered lens items.
52-
// The frontend surfaces the "Unable to load" toast when items is empty + persona_fetch_failed.
53-
if (!bypassActive && personaFetchFailed) {
54-
logger.warning(req, 'build_lens_items', 'Persona fetch failed, returning empty lens items to fail closed', { lens });
28+
if (firstPage.failed || !firstPage.response) {
5529
return {
5630
items: [],
5731
next_page_token: null,
58-
bypass_active: false,
59-
persona_fetch_failed: true,
60-
upstream_failed: firstPage.failed,
32+
upstream_failed: true,
6133
lens,
6234
};
6335
}
6436

65-
const shouldFilter = !bypassActive && !!persona;
66-
const eligibleUids = shouldFilter && persona ? this.collectEligibleProjectUids(persona.projects, LENS_PERSONA_MAP[lens]) : null;
67-
68-
// targetCount=0 when filtering with no eligible projects skips the loop body entirely.
69-
const targetCount = eligibleUids ? eligibleUids.size : NAV_MIN_ITEMS_PER_RESPONSE;
70-
71-
const accumulated: LensItem[] = [];
72-
let iterations = 0;
73-
let lastToken: string | null = null;
74-
let upstreamFailed = firstPage.failed;
75-
let currentResponse: QueryServiceResponse<Project> | null = firstPage.response;
76-
77-
while (currentResponse && accumulated.length < targetCount && iterations < NAV_MAX_UPSTREAM_ITERATIONS) {
78-
iterations += 1;
79-
const pageItems = this.filterPageResources(currentResponse.resources, lens, eligibleUids);
80-
accumulated.push(...pageItems.map((p) => this.toLensItem(p)));
81-
lastToken = currentResponse.page_token ?? null;
82-
83-
if (!lastToken || accumulated.length >= targetCount) break;
84-
85-
const next = await this.fetchUpstreamPage(req, lens, lastToken, name);
86-
if (next.failed) {
87-
upstreamFailed = true;
88-
lastToken = null;
89-
break;
90-
}
91-
currentResponse = next.response;
92-
}
93-
94-
// With persona filtering, only suppress next-page token when we've proven completeness.
95-
// If the loop exited due to the iteration cap, keep the token so the client can continue.
96-
if (eligibleUids) {
97-
const fullyCollected = accumulated.length >= eligibleUids.size;
98-
if (fullyCollected || upstreamFailed) {
99-
lastToken = null;
100-
}
101-
}
37+
const projects = this.filterPageResources(firstPage.response.resources, lens);
38+
const items: LensItem[] = projects.map((p) => this.toLensItem(p));
39+
const nextPageToken: string | null = firstPage.response.page_token ?? null;
10240

10341
// Ensure the selected project is in the first-page response so navigation from
10442
// other lenses (e.g., Me → Open) doesn't get overridden by the default picker.
10543
if (selectedUid && !pageToken) {
106-
const alreadyIncluded = accumulated.some((item) => item.uid === selectedUid);
44+
const alreadyIncluded = items.some((item) => item.uid === selectedUid);
10745
if (!alreadyIncluded) {
10846
const selectedItem = await this.fetchSelectedItem(req, lens, selectedUid);
10947
if (selectedItem) {
110-
accumulated.unshift(selectedItem);
48+
items.unshift(selectedItem);
11149
}
11250
}
11351
}
11452

11553
logger.debug(req, 'build_lens_items', 'Built lens items', {
11654
lens,
117-
item_count: accumulated.length,
118-
upstream_iterations: iterations,
119-
bypass_active: bypassActive,
120-
persona_fetch_failed: personaFetchFailed,
121-
upstream_failed: upstreamFailed,
122-
has_next_page: !!lastToken,
55+
item_count: items.length,
56+
has_next_page: !!nextPageToken,
12357
});
12458

12559
return {
126-
items: accumulated,
127-
next_page_token: lastToken,
128-
bypass_active: bypassActive,
129-
persona_fetch_failed: personaFetchFailed,
130-
upstream_failed: upstreamFailed,
60+
items,
61+
next_page_token: nextPageToken,
62+
upstream_failed: false,
13163
lens,
13264
};
13365
}
13466

135-
private async fetchPersona(req: Request): Promise<{ persona: PersonaApiResponse | null; failed: boolean }> {
136-
try {
137-
const persona = await personaDetectionService.getPersonas(req);
138-
if (persona.error) {
139-
logger.warning(req, 'fetch_persona', 'Persona fetch returned error, falling back to lens-scoped results only', { error: persona.error });
140-
return { persona: null, failed: true };
141-
}
142-
return { persona, failed: false };
143-
} catch (error) {
144-
logger.warning(req, 'fetch_persona', 'Persona fetch failed, falling back to lens-scoped results only', { err: error });
145-
return { persona: null, failed: true };
146-
}
147-
}
148-
14967
private async fetchUpstreamPage(
15068
req: Request,
15169
lens: NavLens,
@@ -183,15 +101,12 @@ export class NavigationService {
183101
}
184102
}
185103

186-
private filterPageResources(resources: QueryServiceResponse<Project>['resources'], lens: NavLens, eligibleUids: Set<string> | null): Project[] {
104+
private filterPageResources(resources: QueryServiceResponse<Project>['resources'], lens: NavLens): Project[] {
187105
let projects = resources.map((r) => r.data);
188106
// legal_entity_type negation isn't supported by the filter grammar — re-check locally.
189107
if (lens === 'foundation') {
190108
projects = projects.filter((p) => computeIsFoundation(p));
191109
}
192-
if (eligibleUids) {
193-
projects = projects.filter((p) => eligibleUids.has(p.uid));
194-
}
195110
return projects;
196111
}
197112

@@ -210,7 +125,7 @@ export class NavigationService {
210125
base.filters_or = [`stage:${ProjectStage.Active}`, `stage:${ProjectStage.FormationEngaged}`];
211126
} else {
212127
// Include active projects plus supported pre-launch formation stages so
213-
// persona-eligible pre-launch projects appear in the project dropdown.
128+
// pre-launch projects appear in the project dropdown.
214129
base.filters_or = [...PROJECT_LENS_ALLOWED_STAGES].map((stage) => `stage:${stage}`);
215130
}
216131

@@ -220,16 +135,6 @@ export class NavigationService {
220135
return base;
221136
}
222137

223-
private collectEligibleProjectUids(projects: EnrichedPersonaProject[], allowedPersonas: readonly PersonaType[]): Set<string> {
224-
const uids = new Set<string>();
225-
for (const project of projects) {
226-
if (project.personas.some((p) => allowedPersonas.includes(p))) {
227-
uids.add(project.projectUid);
228-
}
229-
}
230-
return uids;
231-
}
232-
233138
private toLensItem(project: Project): LensItem {
234139
return {
235140
uid: project.uid,

packages/shared/src/constants/lens.constants.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// SPDX-License-Identifier: MIT
33

44
import type { Lens, LensOption, NavLens } from '../interfaces';
5-
import type { PersonaType } from '../interfaces/persona.interface';
65

76
export const LENS_COOKIE_KEY = 'lfx-active-lens';
87

@@ -61,11 +60,4 @@ export const DUAL_SCOPED_LENSES: readonly Lens[] = ['me', 'foundation', 'project
6160
/** Lenses backed by the nav API (me/org are not). */
6261
export const NAV_LENSES: readonly NavLens[] = ['foundation', 'project'] as const;
6362

64-
export const LENS_PERSONA_MAP: Readonly<Record<NavLens, readonly PersonaType[]>> = {
65-
foundation: ['board-member', 'executive-director'],
66-
project: ['contributor', 'maintainer', 'executive-director'],
67-
} as const;
68-
69-
export const NAV_MIN_ITEMS_PER_RESPONSE = 15;
70-
export const NAV_MAX_UPSTREAM_ITERATIONS = 10;
7163
export const NAV_SEARCH_DEBOUNCE_MS = 300;

0 commit comments

Comments
 (0)