diff --git a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html index 381eb6899..b4d23949c 100644 --- a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html +++ b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html @@ -4,13 +4,14 @@ @if (mobile()) {
@for (lens of lenses(); track lens.id) { + @let isActive = activeLensId() === lens.id; } @@ -65,7 +66,7 @@
@for (lens of lenses(); track lens.id) { - @let isActive = activeLens() === lens.id; + @let isActive = activeLensId() === lens.id;
-
+
- {{ lensTypeLabel() }} +
+ {{ lensTypeLabel() }} + @if (selectedRoleLabel()) { + + + + + } +
@@ -34,21 +47,42 @@ (onHide)="onPopoverHide()" data-testid="project-selector-panel">
+
- +
+ + @if (hybridMode()) { +
+ @for (tab of selectorTabs; track tab) { + + } +
+ } +
- @for (item of items(); track item.uid; let i = $index) { - + @for (displayItem of displayedItems(); track displayItem.item.uid; let i = $index) { + @if (i === autoLoadTriggerIndex() && hasMore()) { @defer (on viewport) { @@ -57,28 +91,50 @@ } } -
-
-

{{ item.name || item.slug }}

-
- + + +
} @empty { @if (!loading()) {
{{ emptyMessage() }}
diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index d03441637..09dfdd2b3 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -1,54 +1,78 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject, input, model, output, Signal } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { Component, computed, inject, input, model, output, signal, Signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { LensItem, NavLens, ProjectContext } from '@lfx-one/shared/interfaces'; +import { BOARD_SCOPED_PERSONA_PRIORITY, PROJECT_SCOPED_PERSONA_PRIORITY } from '@lfx-one/shared/constants'; +import { DisplayLensItem, LensItem, NavLens, PersonaType, ProjectContext, SelectorTab } from '@lfx-one/shared/interfaces'; +import { LensService } from '@services/lens.service'; import { NavigationService } from '@services/navigation.service'; +import { PersonaService } from '@services/persona.service'; import { UserService } from '@services/user.service'; import { OnRenderDirective } from '@shared/directives/on-render.directive'; import { AutoFocus } from 'primeng/autofocus'; import { InputTextModule } from 'primeng/inputtext'; import { Popover, PopoverModule } from 'primeng/popover'; +import { TooltipModule } from 'primeng/tooltip'; @Component({ selector: 'lfx-project-selector', - imports: [ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus, OnRenderDirective], + imports: [NgClass, ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus, OnRenderDirective, TooltipModule], templateUrl: './project-selector.component.html', styleUrl: './project-selector.component.scss', }) export class ProjectSelectorComponent { private readonly userService = inject(UserService); private readonly navigationService = inject(NavigationService); + private readonly lensService = inject(LensService); + private readonly personaService = inject(PersonaService); public readonly lens = input.required(); public readonly selectedProject = input(null); public readonly searchPlaceholder = input('Search...'); public readonly emptyMessage = input('No results found'); + public readonly hybridMode = input(false); public readonly itemSelected = output(); public readonly isPanelOpen = model(false); + protected readonly activeTab = signal('all'); + protected readonly selectorTabs: readonly SelectorTab[] = ['all', 'foundations', 'projects']; protected readonly searchControl = new FormControl('', { nonNullable: true }); - // Offset the popover when the impersonation banner is visible. - protected readonly panelStyleClass = computed(() => - this.userService.impersonating() ? 'project-selector-panel project-selector-panel--with-banner' : 'project-selector-panel' - ); + protected readonly panelStyleClass: Signal = this.initPanelStyleClass(); + protected readonly lensTypeLabel: Signal = this.initLensTypeLabel(); + protected readonly displayName: Signal = this.initDisplayName(); + protected readonly displayLogo: Signal = computed(() => this.selectedProject()?.logoUrl || ''); + protected readonly selectedRolePersona: Signal = this.initSelectedRolePersona(); + protected readonly selectedRoleLabel: Signal = computed(() => { + const persona = this.selectedRolePersona(); + return persona ? this.personaTypeToLabel(persona) : ''; + }); + protected readonly selectedRoleIcon: Signal = computed(() => { + const persona = this.selectedRolePersona(); + return persona ? this.personaTypeToIcon(persona) : ''; + }); - protected readonly lensTypeLabel = computed(() => (this.lens() === 'foundation' ? 'Foundation' : 'Project')); + protected readonly foundationItems: Signal = this.initFoundationItems(); + protected readonly rawProjectItems: Signal = this.initRawProjectItems(); + protected readonly loading: Signal = this.initLoading(); + protected readonly hasMore: Signal = this.initHasMore(); + protected readonly displayedItems: Signal = this.initDisplayedItems(); - protected readonly displayName: Signal = this.initializeDisplayName(); - protected readonly displayLogo: Signal = this.initializeDisplayLogo(); - protected readonly items: Signal = this.initializeItems(); - protected readonly loading: Signal = this.initializeLoading(); - protected readonly hasMore: Signal = this.initializeHasMore(); - protected readonly autoLoadTriggerIndex: Signal = this.initializeAutoLoadTriggerIndex(); + protected readonly autoLoadTriggerIndex: Signal = computed(() => Math.max(0, this.displayedItems().length - 8)); public constructor() { - // Service applies its own debounce — push raw emissions here. - this.searchControl.valueChanges.pipe(takeUntilDestroyed()).subscribe((term) => this.navigationService.setSearchTerm(this.lens(), term)); + this.searchControl.valueChanges.pipe(takeUntilDestroyed()).subscribe((term) => { + if (this.hybridMode()) { + this.navigationService.setSearchTerm('foundation', term); + this.navigationService.setSearchTerm('project', term); + } else { + this.navigationService.setSearchTerm(this.lens(), term); + } + }); } protected selectItem(item: LensItem, popover: Popover): void { @@ -66,44 +90,251 @@ export class ProjectSelectorComponent { protected onPopoverHide(): void { this.isPanelOpen.set(false); - // Skip emit so UI + service reset in sync via the explicit setSearchTerm below. + this.activeTab.set('all'); this.searchControl.setValue('', { emitEvent: false }); - this.navigationService.setSearchTerm(this.lens(), ''); + if (this.hybridMode()) { + this.navigationService.setSearchTerm('foundation', ''); + this.navigationService.setSearchTerm('project', ''); + } else { + this.navigationService.setSearchTerm(this.lens(), ''); + } } protected loadMore(): void { - this.navigationService.loadNextPage(this.lens()); + if (this.hybridMode()) { + const tab = this.activeTab(); + // All-tab drains foundations first so the higher-priority group completes before standalone projects appear. + if (tab === 'foundations') { + this.navigationService.loadNextPage('foundation'); + return; + } + if (tab === 'projects') { + this.navigationService.loadNextPage('project'); + return; + } + if (this.navigationService.hasMore('foundation')()) { + this.navigationService.loadNextPage('foundation'); + } else { + this.navigationService.loadNextPage('project'); + } + } else { + this.navigationService.loadNextPage(this.lens()); + } + } + + private initPanelStyleClass(): Signal { + return computed(() => (this.userService.impersonating() ? 'project-selector-panel project-selector-panel--with-banner' : 'project-selector-panel')); + } + + private initLensTypeLabel(): Signal { + return computed(() => { + if (this.hybridMode()) { + const selectedUid = this.selectedProject()?.uid; + if (selectedUid) { + const detected = this.personaService.detectedProjects().find((p) => p.projectUid === selectedUid); + if (detected) { + return detected.isFoundation ? 'Foundation' : 'Project'; + } + } + return this.lensService.activeLens() === 'foundation' ? 'Foundation' : 'Project'; + } + return this.lens() === 'foundation' ? 'Foundation' : 'Project'; + }); } - private initializeDisplayName(): Signal { + private initDisplayName(): Signal { return computed(() => { const project = this.selectedProject(); return project?.name?.trim() || `Select ${this.lensTypeLabel()}`; }); } - private initializeDisplayLogo(): Signal { + private initSelectedRolePersona(): Signal { return computed(() => { - const project = this.selectedProject(); - return project?.logoUrl || ''; + const uid = this.selectedProject()?.uid; + if (!uid) return null; + const detected = this.personaService.detectedProjects().find((p) => p.projectUid === uid); + const isFoundation = detected?.isFoundation ?? this.lensService.activeLens() === 'foundation'; + const priority = isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; + const personaProjects = this.personaService.personaProjects(); + for (const persona of priority) { + if ((personaProjects[persona] ?? []).some((p) => p.projectUid === uid)) { + return persona; + } + } + return this.fallbackRolePersona(priority); + }); + } + + private initFoundationItems(): Signal { + return computed(() => (this.hybridMode() ? this.navigationService.items('foundation')() : [])); + } + + private initRawProjectItems(): Signal { + // NavigationService.applyVisibilityFilters already filters foundations from the project lens when the foundation lens is available; re-filtering here would hide rows project-only users are meant to select. + return computed(() => this.navigationService.items(this.hybridMode() ? 'project' : this.lens())()); + } + + private initLoading(): Signal { + return computed(() => { + if (this.hybridMode()) { + return this.navigationService.loading('foundation')() || this.navigationService.loading('project')(); + } + return this.navigationService.loading(this.lens())(); + }); + } + + private initHasMore(): Signal { + return computed(() => { + if (this.hybridMode()) { + const tab = this.activeTab(); + if (tab === 'foundations') return this.navigationService.hasMore('foundation')(); + if (tab === 'projects') return this.navigationService.hasMore('project')(); + return this.navigationService.hasMore('foundation')() || this.navigationService.hasMore('project')(); + } + return this.navigationService.hasMore(this.lens())(); + }); + } + + private initDisplayedItems(): Signal { + return computed(() => { + const selectedUid = this.selectedProject()?.uid ?? null; + if (!this.hybridMode()) { + return this.sortByRole(this.rawProjectItems()).map((item) => this.toDisplayItem(item, false, selectedUid)); + } + const tab = this.activeTab(); + if (tab === 'foundations') { + return this.sortByRole(this.foundationItems()).map((item) => this.toDisplayItem(item, false, selectedUid)); + } + if (tab === 'projects') { + return this.sortByRole(this.rawProjectItems()).map((item) => this.toDisplayItem(item, false, selectedUid)); + } + return this.buildAllTabItems(selectedUid); }); } - private initializeItems(): Signal { - return computed(() => this.navigationService.items(this.lens())()); + private toDisplayItem(item: LensItem, isNested: boolean, selectedUid: string | null): DisplayLensItem { + const persona = this.resolveRolePersona(item); + return { + item, + isNested, + isSelected: selectedUid === item.uid, + roleLabel: persona ? this.personaTypeToLabel(persona) : '', + roleIcon: persona ? this.personaTypeToIcon(persona) : '', + }; } - private initializeLoading(): Signal { - return computed(() => this.navigationService.loading(this.lens())()); + private resolveRolePersona(item: LensItem): PersonaType | null { + const priority = item.isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; + const personaProjects = this.personaService.personaProjects(); + for (const persona of priority) { + if ((personaProjects[persona] ?? []).some((p) => p.projectUid === item.uid)) { + return persona; + } + } + return this.fallbackRolePersona(priority); } - private initializeHasMore(): Signal { - return computed(() => this.navigationService.hasMore(this.lens())()); + // personaProjects only covers projects with explicit detections, but the navigation API returns + // every accessible project. For non-hybrid users the scope is unambiguous (board-only or + // project-only), so we surface the highest-priority persona they hold within that scope. + private fallbackRolePersona(priority: readonly PersonaType[]): PersonaType | null { + if (this.hybridMode()) { + return null; + } + const allPersonas = this.personaService.allPersonas(); + for (const persona of priority) { + if (allPersonas.includes(persona)) { + return persona; + } + } + return null; } - // Sentinel at items.length - 8; signal-based index shifts on each page so Angular re-creates - // OnRenderDirective and re-fires the fetch. - private initializeAutoLoadTriggerIndex(): Signal { - return computed(() => Math.max(0, this.items().length - 8)); + private personaTypeToLabel(persona: PersonaType): string { + const map: Record = { + 'executive-director': 'Executive Director', + 'board-member': 'Board Member', + maintainer: 'Maintainer', + contributor: 'Contributor', + }; + return map[persona] ?? ''; + } + + private personaTypeToIcon(persona: PersonaType): string { + const map: Record = { + 'executive-director': 'fa-light fa-briefcase', + 'board-member': 'fa-light fa-building-columns', + maintainer: 'fa-light fa-code', + contributor: 'fa-light fa-code', + }; + return map[persona] ?? ''; + } + + private roleIndex(item: LensItem): number { + const priority = item.isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; + const personaProjects = this.personaService.personaProjects(); + for (let i = 0; i < priority.length; i++) { + if ((personaProjects[priority[i]] ?? []).some((p) => p.projectUid === item.uid)) { + return i; + } + } + return priority.length; + } + + private sortByRole(items: LensItem[]): LensItem[] { + return [...items].sort((a, b) => { + const diff = this.roleIndex(a) - this.roleIndex(b); + return diff !== 0 ? diff : (a.name ?? '').localeCompare(b.name ?? ''); + }); + } + + private buildAllTabItems(selectedUid: string | null): DisplayLensItem[] { + const sortedFoundations = this.sortByRole(this.foundationItems()); + const sortedProjects = this.sortByRole(this.rawProjectItems()); + + // Pre-group projects by parent foundation in a single pass so nesting is O(F + P), not O(F × P). + const detectedProjects = this.personaService.detectedProjects(); + const parentByProjectUid = new Map(); + for (const dp of detectedProjects) { + if (dp.parentProjectUid) { + parentByProjectUid.set(dp.projectUid, dp.parentProjectUid); + } + } + + const foundationUidSet = new Set(sortedFoundations.map((f) => f.uid)); + const childrenByFoundationUid = new Map(); + const nestedProjectUids = new Set(); + for (const project of sortedProjects) { + const parentUid = parentByProjectUid.get(project.uid); + if (parentUid && foundationUidSet.has(parentUid)) { + const bucket = childrenByFoundationUid.get(parentUid); + if (bucket) { + bucket.push(project); + } else { + childrenByFoundationUid.set(parentUid, [project]); + } + nestedProjectUids.add(project.uid); + } + } + + const result: DisplayLensItem[] = []; + for (const foundation of sortedFoundations) { + result.push(this.toDisplayItem(foundation, false, selectedUid)); + const children = childrenByFoundationUid.get(foundation.uid); + if (children) { + for (const project of children) { + result.push(this.toDisplayItem(project, true, selectedUid)); + } + } + } + + for (const project of sortedProjects) { + if (!nestedProjectUids.has(project.uid)) { + result.push(this.toDisplayItem(project, false, selectedUid)); + } + } + + return result; } } diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index c6db941ef..ab9ac91c4 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -19,9 +19,10 @@ } @else { } @@ -34,14 +35,37 @@

{{ user()?.name }}

- @if (showPersonaBadge()) { -
- @for (tag of personaLabels(); track $index) { - - {{ tag.label }} - - } -
+ @if (showPersonaBadge() && personaLabels().length > 0) { + @if (personaLabels().length === 1) { + + {{ personaLabels()[0].label }} + + } @else { +
+ @for (tag of personaLabels(); track $index) { + +
{{ tag.label }}
+ @if (tag.names.length > 0) { +
    + @for (name of tag.names; track $index) { +
  • {{ name }}
  • + } +
+ } +
+ + + + } +
+ } }
diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss index fbb2d34cb..7fa3fcb43 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss @@ -14,3 +14,11 @@ height: 1rem; width: auto; } + +// Tooltip used by the persona role circles in the Me lens. +// Override PrimeNG defaults so the tooltip width hugs its content and bullet rows don't wrap. +::ng-deep .persona-tooltip .p-tooltip-text { + max-width: none; + width: max-content; + white-space: nowrap; +} diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts index c02016153..5445f6de5 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts @@ -17,6 +17,7 @@ import { PersonaService } from '@services/persona.service'; import { ProjectContextService } from '@services/project-context.service'; import { UserService } from '@services/user.service'; import { SkeletonModule } from 'primeng/skeleton'; +import { TooltipModule } from 'primeng/tooltip'; const PERSONA_ICONS: Partial> = { 'executive-director': 'fa-light fa-briefcase', @@ -27,7 +28,7 @@ const PERSONA_ICONS: Partial> = { @Component({ selector: 'lfx-sidebar', - imports: [NgClass, NgTemplateOutlet, RouterModule, AvatarComponent, BadgeComponent, ProjectSelectorComponent, SkeletonModule], + imports: [NgClass, NgTemplateOutlet, RouterModule, AvatarComponent, BadgeComponent, ProjectSelectorComponent, SkeletonModule, TooltipModule], templateUrl: './sidebar.component.html', styleUrl: './sidebar.component.scss', }) @@ -49,13 +50,14 @@ export class SidebarComponent { protected readonly activeLens = this.lensService.activeLens; protected readonly isOrgLens = computed(() => this.activeLens() === 'org'); + protected readonly isHybridPersona = this.lensService.isHybridPersona; protected readonly selectedProject: Signal = computed(() => this.projectContextService.activeContext()); protected readonly navLens: Signal = this.initNavLens(); protected readonly lensLoaded: Signal = this.initLensLoaded(); protected readonly user = this.userService.user; protected readonly userInitials = this.userService.userInitials; - protected readonly personaLabels: Signal<{ label: string; icon: string }[]> = this.initPersonaLabels(); + protected readonly personaLabels: Signal<{ label: string; icon: string; names: string[]; ariaLabel: string }[]> = this.initPersonaLabels(); // Hide the persona badge when the user is a root-writer — executive-director is spoofed, not naturally detected. protected readonly showPersonaBadge: Signal = computed(() => !this.personaService.isRootWriter()); @@ -82,12 +84,16 @@ export class SidebarComponent { protected onItemSelected(item: LensItem): void { const context = lensItemToProjectContext(item); - const lens = this.lensService.activeLens(); - - if (lens === 'foundation') { + // Project-only users still see foundations in their project list (NavigationService only filters + // foundations out when the foundation lens is visible). Treat a foundation row as a project context + // for those users — setLens('foundation') would be a no-op and the selection would silently fail. + const foundationAllowed = this.lensService.availableLenses().some((option) => option.id === 'foundation'); + if (item.isFoundation && foundationAllowed) { this.projectContextService.setFoundation(context); - } else if (lens === 'project') { + this.lensService.setLens('foundation'); + } else { this.projectContextService.setProject(context); + this.lensService.setLens('project'); } } @@ -107,11 +113,16 @@ export class SidebarComponent { }); } - private initPersonaLabels(): Signal<{ label: string; icon: string }[]> { + private initPersonaLabels(): Signal<{ label: string; icon: string; names: string[]; ariaLabel: string }[]> { return computed(() => { + const personaProjects = this.personaService.personaProjects(); const toTag = (p: PersonaType) => { const option = PERSONA_OPTIONS.find((o) => o.value === p); - return { label: option?.label ?? toTitleCase(p), icon: PERSONA_ICONS[p] ?? 'fa-light fa-user' }; + const label = option?.label ?? toTitleCase(p); + const icon = PERSONA_ICONS[p] ?? 'fa-light fa-user'; + const names = (personaProjects[p] ?? []).map((proj) => proj.projectName).filter((n): n is string => !!n); + const ariaLabel = names.length ? `Role: ${label} (${names.join(', ')})` : `Role: ${label}`; + return { label, icon, names, ariaLabel }; }; if (this.activeLens() === 'me') { diff --git a/apps/lfx-one/src/app/shared/services/lens.service.ts b/apps/lfx-one/src/app/shared/services/lens.service.ts index 299a03660..c3cba85c2 100644 --- a/apps/lfx-one/src/app/shared/services/lens.service.ts +++ b/apps/lfx-one/src/app/shared/services/lens.service.ts @@ -21,7 +21,12 @@ export class LensService { /** Active lens clamped to the current persona's allowed set; falls back to default if disallowed. */ public readonly activeLens: Signal = this.initActiveLens(); + /** Full set of lenses the current persona is authorised to use — drives routing and downstream visibility filters. */ public readonly availableLenses: Signal = this.initAvailableLenses(); + /** Lenses shown in the sidebar switcher. Mirrors {@link availableLenses} except for hybrid personas, who get a merged project entry instead of separate foundation + project buttons. */ + public readonly displayLenses: Signal = this.initDisplayLenses(); + /** True when the user holds both a board role (ED/Board Member) AND a project role (Maintainer/Contributor). */ + public readonly isHybridPersona: Signal = computed(() => this.personaService.hasBoardRole() && this.personaService.hasProjectRole()); public constructor() { const stored = this.loadFromCookie(); @@ -49,9 +54,14 @@ export class LensService { } private initAvailableLenses(): Signal { + return computed(() => this.getAllowedLensIds().map((id) => ALL_LENSES[id])); + } + + private initDisplayLenses(): Signal { return computed(() => { - const lensIds = this.getAllowedLensIds(); - return lensIds.map((id) => ALL_LENSES[id]); + const lenses = this.availableLenses(); + // For hybrid personas the 'project' button serves as the merged entry — hide the separate foundation button. + return this.isHybridPersona() ? lenses.filter((option) => option.id !== 'foundation') : lenses; }); } diff --git a/apps/lfx-one/src/app/shared/services/navigation.service.ts b/apps/lfx-one/src/app/shared/services/navigation.service.ts index e45ea4381..2335f161a 100644 --- a/apps/lfx-one/src/app/shared/services/navigation.service.ts +++ b/apps/lfx-one/src/app/shared/services/navigation.service.ts @@ -36,7 +36,16 @@ export class NavigationService { map((lens): NavLens | null => (lens === 'foundation' || lens === 'project' ? lens : null)), distinctUntilChanged(), filter((lens): lens is NavLens => lens !== null), - tap((lens) => this.resetAndReload(lens)) + tap((lens) => { + this.resetAndReload(lens); + // For hybrid personas, preload the sibling lens so the merged dropdown has both sets ready. + // preloadSibling() skips default-selection side effects so it can't overwrite the active + // lens's context/URL, and it bails out if the sibling is already loaded or in flight. + if (this.lensService.isHybridPersona()) { + const sibling: NavLens = lens === 'foundation' ? 'project' : 'foundation'; + this.preloadSibling(sibling); + } + }) ), { initialValue: null } ); @@ -54,6 +63,24 @@ export class NavigationService { { initialValue: false } ); + // Persona refresh can promote a user to hybrid without changing activeLens — in that case the + // activeLensPreloader doesn't re-run, leaving the sibling lens unloaded and the merged tabs empty. + private readonly hybridTransitionPreloader = toSignal( + toObservable(this.lensService.isHybridPersona).pipe( + distinctUntilChanged(), + skip(1), + filter((isHybrid) => isHybrid), + tap(() => { + const active = this.lensService.activeLens(); + if (active === 'foundation' || active === 'project') { + const sibling: NavLens = active === 'foundation' ? 'project' : 'foundation'; + this.preloadSibling(sibling); + } + }) + ), + { initialValue: false } + ); + public items(lens: NavLens): Signal { return this.getState(lens).items; } @@ -93,6 +120,16 @@ export class NavigationService { state.reload$.next(); } + private preloadSibling(lens: NavLens): void { + const state = this.getState(lens); + if (state.loaded() || state.loading()) { + return; + } + // Deliberately do NOT set pendingDefaultSelection — preload must not race with the active + // lens's selection and can't be allowed to overwrite the URL/context. + state.reload$.next(); + } + private getState(lens: NavLens): LensState { return lens === 'foundation' ? this.foundationState : this.projectState; } diff --git a/packages/shared/src/constants/lens.constants.ts b/packages/shared/src/constants/lens.constants.ts index edadf32b1..4ef45ae78 100644 --- a/packages/shared/src/constants/lens.constants.ts +++ b/packages/shared/src/constants/lens.constants.ts @@ -35,10 +35,10 @@ export const ALL_LENSES: Readonly> = { }, project: { id: 'project', - label: 'Project', - shortLabel: 'Project', - icon: 'fa-light fa-laptop-code', - activeIcon: 'fa-solid fa-laptop-code', + label: 'Projects', + shortLabel: 'Projects', + icon: 'fa-light fa-layer-group', + activeIcon: 'fa-solid fa-layer-group', defaultRoute: LENS_DEFAULT_ROUTES.project, testId: 'lens-project', }, diff --git a/packages/shared/src/interfaces/lens.interface.ts b/packages/shared/src/interfaces/lens.interface.ts index 20c7c57a6..5983e66df 100644 --- a/packages/shared/src/interfaces/lens.interface.ts +++ b/packages/shared/src/interfaces/lens.interface.ts @@ -1,6 +1,8 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import type { LensItem } from './navigation.interface'; + /** * Navigation lens types * Each lens represents a top-level navigation context that determines sidebar content @@ -26,3 +28,21 @@ export interface LensOption { /** Test ID for the lens button */ testId: string; } + +/** + * Tab options for the project selector in hybrid persona mode + */ +export type SelectorTab = 'all' | 'foundations' | 'projects'; + +/** + * Precomputed per-row state for the project selector dropdown. + * All fields are derived once in the displayedItems computed so the template binds to plain + * values — no functions in template bindings (signals-first / zoneless memoization). + */ +export interface DisplayLensItem { + item: LensItem; + isNested: boolean; + isSelected: boolean; + roleLabel: string; + roleIcon: string; +}