Skip to content

Commit cca73f6

Browse files
authored
feat(nav): merge foundation and project lenses for hybrid personas (#713)
* feat(nav): merge foundation/project lenses for hybrid personas - Add isHybridPersona signal to LensService; availableLenses suppresses the separate foundation button for hybrid users - Preload both lens item sets in NavigationService for hybrid users so the merged dropdown has both ready immediately - Add isLensActive() helper to LensSwitcherComponent so the merged Projects button appears active for both foundation and project states - Rewrite ProjectSelectorComponent with hybrid mode: All/Foundations/ Projects tabs, role-sorted items (ED→BM→alpha / Maintainer→Contributor →alpha), projects nested under parent foundation in the All tab, and a transparent/borderless search input - Update SidebarComponent to switch the active lens based on item.isFoundation when an item is selected in hybrid mode - Update project lens constant: icon → fa-layer-group, label → Projects Signed-off-by: Nuno Eufrasio <nuno.eufrasio@linuxfoundation.org> Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(nav): exclude foundations from project lens dropdown The project-lens API can include foundations the user has access to, but those belong in the foundation lens (or the Foundations tab in hybrid mode) — never in the projects list. Filter them out so a project-only Contributor doesn't see foundation entries. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * refactor(nav): refine role display in lens selector and me card Polish how persona roles surface across the hybrid lens experience: - selector trigger now shows a compact role-icon badge with tooltip instead of inline text, with left-aligned, truncating description - dropdown rows render the role icon + label inline beneath each project/foundation name - nested project connector lines align with the parent foundation logo - me-lens user card collapses to inline "<icon> Role" for a single persona and to circular icon-only badges with a rich HTML tooltip (role name + bulleted project/foundation list) for multiple personas - tooltip width hugs content so role/project entries never wrap Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(nav): address review feedback on hybrid lens selector - Scope hybrid pagination (hasMore/loadMore) to the active tab so the Projects tab can keep advancing instead of exhausting foundations first. - Restore `availableLenses` to the full allowed set and add a separate `displayLenses` for the sidebar switcher, so downstream consumers (NavigationService.applyVisibilityFilters / foundationVisibilityWatcher) keep filtering foundations out of the project lens for hybrid users. - Replace `[class]` with `[ngClass]` on role icons so the static `text-[10px]` styling isn't wiped out. - Make tooltip-only role badges (selector trigger + Me-card multi-role) focusable via `tabindex="0"` + `role="img"` + `aria-label`, with a focus ring so keyboard users can reveal the role info. - Drop `role=tablist`/`role=tab` from the hybrid filter pills (they aren't a true tabs widget); use `role=group` + `aria-pressed` instead. - Swap hard-coded SVG hex fills on the fallback logo for `fill-blue-500` and `fill-blue-800` tokens. - Pre-group nested projects by parent uid so `buildAllTabItems` runs in O(F + P) rather than O(F × P). - Fix the stale comment that described the parent-uid map as the inverse of what the code actually builds. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * refactor(nav): apply second-round review feedback on lens merge - Precompute per-row selector state (isSelected, roleLabel, roleIcon) into DisplayLensItem so the template stops re-invoking methods on every change-detection pass. - Move DisplayLensItem + SelectorTab into @lfx-one/shared/interfaces. - Restore the initializeXxx() pattern for complex computeds in project-selector so the field block stays scannable. - Replace lens-switcher.isLensActive() method with an activeLensId computed and use it directly in templates. - Replace sidebar persona-tooltip HTML-string building (with [escape]=false) with structured data + an inline <ng-template> rendered through pTooltip, so Angular handles escaping. - Collapse the two remaining multi-line inline comments to single lines. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(nav): address third-round review feedback on lens merge - Use a type-only import in lens.interface.ts to break a runtime circular dependency with navigation.interface.ts. - Reorder sidebar.onItemSelected so the project/foundation context is set before the lens flips. NavigationService injects selected_uid from ProjectContextService at reload time, and the previous ordering let the lens reload run with the stale selection. - Add a preloadSibling() path in NavigationService that triggers a reload without setting pendingDefaultSelection and bails when the sibling lens is already loaded or in flight, so hybrid preload can't race with the active lens or overwrite the URL/context. - Hybrid-aware emptyMessage on the project selector — show 'No results found' instead of a single-lens-specific copy when the dropdown is rendering mixed All/Foundations/Projects tabs. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(nav): fall back to user persona when item lookup misses For non-hybrid users, personaProjects only contains projects with explicit role detections, but the navigation API returns every accessible project. The role badge/description was missing whenever a listed item had no detection entry — even though the user's scope (board-only or project-only) makes the role unambiguous. When the per-item lookup misses for a non-hybrid user, surface the highest-priority persona they hold (executive-director → board-member, maintainer → contributor) so every row in the dropdown and the trigger badge consistently show the role icon and label. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(nav): address PR #713 review feedback - project-selector.component.html: remove nested tabindex="0" from role badge span inside trigger button; the parent button already handles focus/hover for the pTooltip, so the inner span doesn't need its own tab stop (invalid HTML). - navigation.service.ts: add hybridTransitionPreloader so the sibling lens preloads when a persona refresh promotes the user to hybrid mid-session without an activeLens change. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(nav): address PR #713 fourth-round review feedback - sidebar.component.ts: project-only users see foundations in their project list (NavigationService only filters foundations out when the foundation lens is visible). onItemSelected now treats foundation rows as a project context for those users so setLens('foundation') doesn't silently no-op and leave the selection unchanged. - sidebar.component.html: switch persona tooltip @for from `track name` to `track $index` — project/foundation names can legitimately repeat and would produce duplicate track keys. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(nav): address PR #713 fifth-round review feedback - project-selector.component.ts: remove unused `items` signal alias — template binds to `displayedItems()` and the class never reads it. - sidebar.component.ts/html: include the associated project names in the persona badge `aria-label` so screen-reader users get the same info as the hover tooltip without relying on hover-only UI. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(nav): stop double-filtering foundations from project lens NavigationService.applyVisibilityFilters already strips foundations from the project lens for users who can switch to the foundation lens (hybrid + ED/Board-only). For project-only users it intentionally keeps them so they can still be selected. The component-level filter was redundant for the first group and silently hid foundations from project-only users — also bypassing sidebar.onItemSelected's path for handling foundation rows in that case. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> --------- Signed-off-by: Nuno Eufrasio <nuno.eufrasio@linuxfoundation.org> Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com>
1 parent a8d8397 commit cca73f6

11 files changed

Lines changed: 492 additions & 88 deletions

File tree

apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
@if (mobile()) {
55
<div class="flex items-center gap-1 px-3 py-2" data-testid="lens-switcher-mobile">
66
@for (lens of lenses(); track lens.id) {
7+
@let isActive = activeLensId() === lens.id;
78
<button
89
type="button"
910
(click)="setLens(lens.id)"
1011
[attr.data-testid]="lens.testId + '-mobile'"
1112
class="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors"
12-
[ngClass]="activeLens() === lens.id ? 'bg-indigo-900 text-white' : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'">
13-
<i [ngClass]="activeLens() === lens.id ? lens.activeIcon : lens.icon" class="text-sm"></i>
13+
[ngClass]="isActive ? 'bg-indigo-900 text-white' : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'">
14+
<i [ngClass]="isActive ? lens.activeIcon : lens.icon" class="text-sm"></i>
1415
<span>{{ lens.shortLabel }}</span>
1516
</button>
1617
}
@@ -65,7 +66,7 @@
6566
<!-- Lens Buttons -->
6667
<div class="flex flex-col items-center gap-3 flex-1">
6768
@for (lens of lenses(); track lens.id) {
68-
@let isActive = activeLens() === lens.id;
69+
@let isActive = activeLensId() === lens.id;
6970
<button
7071
type="button"
7172
(click)="setLens(lens.id)"

apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts

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

44
import { NgClass } from '@angular/common';
5-
import { afterNextRender, Component, computed, inject, input, signal, viewChild } from '@angular/core';
5+
import { afterNextRender, Component, computed, inject, input, signal, Signal, viewChild } from '@angular/core';
66
import { Router, RouterLink } from '@angular/router';
77
import { AvatarComponent } from '@components/avatar/avatar.component';
88
import { ButtonComponent } from '@components/button/button.component';
@@ -35,7 +35,13 @@ export class LensSwitcherComponent {
3535
public readonly mobile = input<boolean>(false);
3636

3737
protected readonly activeLens = this.lensService.activeLens;
38-
protected readonly lenses = this.lensService.availableLenses;
38+
protected readonly lenses = this.lensService.displayLenses;
39+
protected readonly isHybrid = this.lensService.isHybridPersona;
40+
// Hybrid personas merge the 'project' button with the 'foundation' lens state — both map to 'project' for highlighting.
41+
protected readonly activeLensId: Signal<Lens> = computed(() => {
42+
const active = this.activeLens();
43+
return this.isHybrid() && active === 'foundation' ? 'project' : active;
44+
});
3945
protected readonly user = this.userService.user;
4046
protected readonly insightsUrl = buildInsightsUrl();
4147
protected readonly userMenu = viewChild<Popover>('userMenu');

apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html

Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,22 @@
1717
</div>
1818
</div>
1919

20-
<div class="flex flex-col gap-0.5 grow items-start relative min-w-0 overflow-hidden">
20+
<div class="flex flex-col gap-1.5 grow items-start relative min-w-0 overflow-hidden">
2121
<p class="sidebar-project-name" [attr.title]="displayName()">{{ displayName() }}</p>
22-
<span class="text-xs font-normal text-gray-400 leading-none">{{ lensTypeLabel() }}</span>
22+
<div class="flex items-center gap-1 w-full min-w-0 text-left">
23+
<span class="text-xs font-normal text-gray-400 leading-none truncate">{{ lensTypeLabel() }}</span>
24+
@if (selectedRoleLabel()) {
25+
<span class="text-xs font-normal text-gray-400 leading-none shrink-0" aria-hidden="true"></span>
26+
<span
27+
role="img"
28+
[attr.aria-label]="'Role: ' + selectedRoleLabel()"
29+
[pTooltip]="selectedRoleLabel()"
30+
tooltipPosition="top"
31+
class="inline-flex items-center justify-center size-5 rounded-full bg-white border border-gray-200 text-gray-700 shrink-0">
32+
<i [ngClass]="selectedRoleIcon()" class="text-[10px]" aria-hidden="true"></i>
33+
</span>
34+
}
35+
</div>
2336
</div>
2437

2538
<i class="fa-light fa-angles-up-down shrink-0 text-gray-400" aria-hidden="true"></i>
@@ -34,21 +47,42 @@
3447
(onHide)="onPopoverHide()"
3548
data-testid="project-selector-panel">
3649
<div class="p-1">
50+
<!-- Search input — no background or border -->
3751
<div class="relative mb-2">
38-
<i class="fa-light fa-search absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"></i>
52+
<i class="fa-light fa-search absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"></i>
3953
<input
4054
[pAutoFocus]="true"
4155
pInputText
4256
type="text"
4357
[formControl]="searchControl"
4458
[placeholder]="searchPlaceholder()"
45-
class="pl-10 h-9 w-full text-sm bg-gray-50"
59+
class="pl-10 h-9 w-full text-sm !border-0 !bg-transparent !shadow-none focus:!ring-0"
4660
data-testid="project-search-input" />
4761
</div>
4862

63+
<!-- Tab switcher — hybrid mode only. These are filter buttons, not an ARIA tabs widget, so we
64+
use aria-pressed instead of role="tab" to avoid promising tab-panel semantics we don't ship. -->
65+
@if (hybridMode()) {
66+
<div class="flex gap-1 mb-5 bg-gray-100 rounded-full p-1" role="group" aria-label="Filter results">
67+
@for (tab of selectorTabs; track tab) {
68+
<button
69+
type="button"
70+
[attr.aria-pressed]="activeTab() === tab"
71+
(click)="activeTab.set(tab)"
72+
class="flex-1 text-sm font-medium py-1 px-3 rounded-full transition-colors capitalize"
73+
[class.bg-white]="activeTab() === tab"
74+
[class.shadow-sm]="activeTab() === tab"
75+
[class.text-gray-900]="activeTab() === tab"
76+
[class.text-gray-500]="activeTab() !== tab">
77+
{{ tab === 'all' ? 'All' : tab === 'foundations' ? 'Foundations' : 'Projects' }}
78+
</button>
79+
}
80+
</div>
81+
}
82+
4983
<div class="max-h-[400px] overflow-y-auto">
50-
@for (item of items(); track item.uid; let i = $index) {
51-
<!-- Auto-load sentinel 8 items from the end — shifts each page so lfxOnRender re-fires. -->
84+
@for (displayItem of displayedItems(); track displayItem.item.uid; let i = $index) {
85+
<!-- Auto-load sentinel 8 items from the end -->
5286
@if (i === autoLoadTriggerIndex() && hasMore()) {
5387
@defer (on viewport) {
5488
<div lfxOnRender (rendered)="loadMore()" class="h-0 w-full" aria-hidden="true"></div>
@@ -57,28 +91,50 @@
5791
}
5892
}
5993

60-
<button
61-
type="button"
62-
class="flex items-center gap-3 p-2 w-full text-left hover:bg-gray-100 rounded-lg transition-colors"
63-
(click)="selectItem(item, popover)"
64-
[attr.data-testid]="'lens-item-' + item.slug">
65-
<div class="bg-gray-100 overflow-clip rounded-lg shrink-0 size-9 border border-gray-200">
66-
<div class="flex items-center justify-center size-full p-1.5">
67-
@if (item.logoUrl) {
68-
<img [alt]="item.name" class="w-full h-full object-contain" [src]="item.logoUrl" />
69-
} @else {
70-
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
71-
<path d="M3.96369 15.9789V7.98987H0V19.9504H11.9372V15.9789H3.96369Z" fill="#0094FF" />
72-
<path d="M19.8645 0H0V6.00333H3.96369V4.01761H15.9009V15.9781H13.919V19.9495H19.8645V0Z" fill="#003778" />
73-
</svg>
94+
<div
95+
class="mb-1"
96+
[class.pl-5]="displayItem.isNested"
97+
[class.border-l-2]="displayItem.isNested"
98+
[class.border-gray-100]="displayItem.isNested"
99+
[class.ml-[18px]]="displayItem.isNested">
100+
<button
101+
type="button"
102+
class="flex items-center gap-3 p-2 w-full text-left rounded-lg transition-colors"
103+
[class.bg-blue-50]="displayItem.isSelected"
104+
[class.hover:bg-gray-100]="!displayItem.isSelected"
105+
(click)="selectItem(displayItem.item, popover)"
106+
[attr.data-testid]="'lens-item-' + displayItem.item.slug"
107+
[attr.data-selected]="displayItem.isSelected">
108+
<div class="bg-gray-100 overflow-clip rounded-lg shrink-0 size-9 border border-gray-200">
109+
<div class="flex items-center justify-center size-full p-1.5">
110+
@if (displayItem.item.logoUrl) {
111+
<img [alt]="displayItem.item.name" class="w-full h-full object-contain" [src]="displayItem.item.logoUrl" />
112+
} @else {
113+
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
114+
<path d="M3.96369 15.9789V7.98987H0V19.9504H11.9372V15.9789H3.96369Z" class="fill-blue-500" />
115+
<path d="M19.8645 0H0V6.00333H3.96369V4.01761H15.9009V15.9781H13.919V19.9495H19.8645V0Z" class="fill-blue-800" />
116+
</svg>
117+
}
118+
</div>
119+
</div>
120+
121+
<div class="flex-1 min-w-0">
122+
<p class="font-medium text-sm text-gray-900 mb-0 truncate">{{ displayItem.item.name || displayItem.item.slug }}</p>
123+
@if (displayItem.roleLabel) {
124+
<p class="text-xs text-gray-400 leading-none mt-0.5 truncate">
125+
<i [ngClass]="displayItem.roleIcon" class="mr-1" aria-hidden="true"></i>{{ displayItem.roleLabel }}
126+
</p>
74127
}
75128
</div>
76-
</div>
77129

78-
<div class="flex-1 min-w-0">
79-
<p class="font-medium text-sm text-gray-900 mb-0">{{ item.name || item.slug }}</p>
80-
</div>
81-
</button>
130+
<i
131+
class="fa-light fa-check text-blue-500 shrink-0 transition-opacity"
132+
[class.opacity-100]="displayItem.isSelected"
133+
[class.opacity-0]="!displayItem.isSelected"
134+
[attr.aria-label]="displayItem.isSelected ? 'Selected' : null"
135+
[attr.aria-hidden]="!displayItem.isSelected"></i>
136+
</button>
137+
</div>
82138
} @empty {
83139
@if (!loading()) {
84140
<div class="text-center py-8 text-gray-500 text-sm">{{ emptyMessage() }}</div>

0 commit comments

Comments
 (0)