-
- @if (item.logoUrl) {
-
![]()
- } @else {
-
+
+
-
-
{{ 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