Skip to content

Commit da49484

Browse files
feat(org-lens): drill into project detail from projects table counts (#1142)
* feat(org-lens): drill into project detail from projects table counts Make the Contributors and Participants counts on the Org Lens Projects table the entry point to the people they represent, so clicking a count drills into the project detail (agreed with design). LFXV2-2785. - org-projects.component.html: render Contributors and Participants counts as links — Contributors to /org/projects/:slug?card=contributors and Participants to /org/projects/:slug - org-project-detail.component.ts: read the new ?card= query param and auto-open the matching metric drawer once the influence card loads (browser-only, one-shot per navigation; unknown/absent keys are no-ops) - org-lens-project-detail.constants.ts: add shared PD_DRAWER_QUERY_PARAM, PD_CONTRIBUTORS_CARD_KEY, and PD_VALID_DRAWER_CARD_KEYS constants - counts use !text-gray-900 hover:!text-gray-900 to stay neutral: the shared table SCSS forces text-blue-500 on every <a> in a td, so the important override keeps counts neutral while the project-name link keeps the default blue - e2e: cover count-to-detail navigation and drawer auto-open Signed-off-by: ahmedomosanya <aopeyemi@contractor.linuxfoundation.org> * fix(org-lens): correct drawer re-open on reuse and count aria-labels Address Copilot review on PR #1142: - Auto-open the metric drawer via a per-(slug,card) navToken with distinctUntilChanged instead of a lifetime take(1), so the drawer re-opens when Angular reuses the project-detail component across projects/:projectSlug navigations. - Pluralize the Contributors/Participants count aria-labels and correlate them with the visible count; precompute the labels into the row view-model per the component's convention. Signed-off-by: ahmedomosanya <aopeyemi@contractor.linuxfoundation.org> * fix(org-lens): keep drawer closed after dismiss across influence reloads Address Cursor Bugbot on PR #1142: filter the null (cards-loading) emissions before distinctUntilChanged so an influence-block reload (time-range change, retry) that briefly empties the cards can no longer reset the navToken baseline and re-open a drawer the user already dismissed. New navigations still re-open correctly. Signed-off-by: ahmedomosanya <aopeyemi@contractor.linuxfoundation.org> --------- Signed-off-by: ahmedomosanya <aopeyemi@contractor.linuxfoundation.org>
1 parent ab03aa8 commit da49484

7 files changed

Lines changed: 104 additions & 2 deletions

File tree

apps/lfx-one/e2e/org-project-detail.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,33 @@ test.describe('Org Project Detail — leaderboards', () => {
129129
});
130130
});
131131

132+
test.describe('Org Project Detail — Contributors drawer deep-link', () => {
133+
test('auto-opens the Contributors drawer from ?card=contributors', async ({ page }) => {
134+
await page.goto(`${DETAIL_URL}?card=contributors`, { waitUntil: 'domcontentloaded' });
135+
await expect(page.getByTestId('project-detail-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
136+
137+
await expect(page.getByTestId('project-detail-technical-card-contributors')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
138+
await expect(page.getByTestId('influence-card-detail-title')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
139+
await expect(page.getByTestId('influence-card-detail-title')).toHaveText('Contributors');
140+
});
141+
142+
test('closing the auto-opened drawer stays on the detail page', async ({ page }) => {
143+
await page.goto(`${DETAIL_URL}?card=contributors`, { waitUntil: 'domcontentloaded' });
144+
await expect(page.getByTestId('influence-card-detail-title')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
145+
146+
await page.keyboard.press('Escape');
147+
await expect(page.getByTestId('influence-card-detail-title')).toBeHidden();
148+
await expect(page.getByTestId('project-detail-page')).toBeVisible();
149+
await expect(page).toHaveURL(/\/org\/projects\/k8s/);
150+
});
151+
152+
test('ignores an unknown ?card= value and loads the page with no drawer', async ({ page }) => {
153+
await page.goto(`${DETAIL_URL}?card=bogus-card`, { waitUntil: 'domcontentloaded' });
154+
await expect(page.getByTestId('project-detail-technical-group')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
155+
await expect(page.getByTestId('influence-card-detail-title')).toBeHidden();
156+
});
157+
});
158+
132159
test.describe('Org Project Detail — not found', () => {
133160
test('renders the 404 panel for an unknown slug', async ({ page }) => {
134161
await page.goto(DETAIL_URL_BOGUS, { waitUntil: 'domcontentloaded' });

apps/lfx-one/e2e/org-projects.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,27 @@ test.describe('Org Projects', () => {
164164
await expect(page).toHaveURL(/[?&]sort=name/);
165165
});
166166

167+
test('Contributors count links to the project detail page with the Contributors drawer param', async ({ page }) => {
168+
await gotoOrgProjectsPage(page);
169+
await expect(page.getByTestId('org-projects-row-kubernetes')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
170+
171+
const contributors = page.getByTestId('org-projects-contributors-kubernetes');
172+
await expect(contributors).toHaveAttribute('aria-label', /View 1 contributor for Kubernetes/);
173+
await contributors.click();
174+
175+
await expect(page).toHaveURL(/\/org\/projects\/kubernetes\?(?:.*&)?card=contributors/);
176+
});
177+
178+
test('Participants count links to the project detail page without a drawer param', async ({ page }) => {
179+
await gotoOrgProjectsPage(page);
180+
await expect(page.getByTestId('org-projects-row-kubernetes')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
181+
182+
await page.getByTestId('org-projects-participants-kubernetes').click();
183+
184+
await expect(page).toHaveURL(/\/org\/projects\/kubernetes(?:\?|$)/);
185+
expect(page.url()).not.toContain('card=');
186+
});
187+
167188
test('opens the workspace dropdown and the add-workspace dialog', async ({ page }) => {
168189
await gotoOrgProjectsPage(page);
169190
await expect(page.getByTestId('org-projects-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });

apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ import {
2525
PD_DEFAULT_TAB,
2626
PD_VALID_TABS,
2727
PD_DEFAULT_TIME_RANGE,
28+
PD_DRAWER_QUERY_PARAM,
2829
PD_HEALTH_TAG,
2930
PD_NON_LF_MARKER,
31+
PD_VALID_DRAWER_CARD_KEYS,
3032
lfxColors,
3133
PD_METRIC_OPTIONS,
3234
PD_STACKED_PALETTE,
@@ -138,6 +140,10 @@ export class OrgProjectDetailComponent {
138140
protected readonly hasCompany = computed(() => !!this.accountContext.selectedAccount().uid);
139141
private readonly orgName = computed(() => this.accountContext.selectedAccount()?.accountName ?? '');
140142
protected readonly projectSlug = toSignal(this.route.paramMap.pipe(map((params) => params.get('projectSlug'))), { initialValue: null });
143+
private readonly drawerCardParam = computed<string | null>(() => {
144+
const raw = this.queryParamMap().get(PD_DRAWER_QUERY_PARAM);
145+
return raw && PD_VALID_DRAWER_CARD_KEYS.has(raw) ? raw : null;
146+
});
141147

142148
// Fetch triggers. Hero is range-independent (drops range$); the tab-scoped blocks gate on an
143149
// "activated" flag that flips true the first time their tab is shown and stays true, so returning
@@ -288,6 +294,24 @@ export class OrgProjectDetailComponent {
288294
this.refreshArrows(this.techTrackRef()?.nativeElement, true);
289295
this.refreshArrows(this.ecoTrackRef()?.nativeElement, false);
290296
});
297+
298+
if (isPlatformBrowser(this.platformId)) {
299+
toObservable(
300+
computed(() => {
301+
const slug = this.projectSlug();
302+
const key = this.drawerCardParam();
303+
if (!slug || !key) return null;
304+
const card = [...this.technicalCards(), ...this.ecosystemCards()].find((c) => c.key === key) ?? null;
305+
return card ? { navToken: `${slug}|${key}`, card } : null;
306+
})
307+
)
308+
.pipe(
309+
filter((match): match is { navToken: string; card: InfluenceCardVm } => match !== null),
310+
distinctUntilChanged((a, b) => a.navToken === b.navToken),
311+
takeUntilDestroyed()
312+
)
313+
.subscribe(({ card }) => this.openCardDetail(card));
314+
}
291315
}
292316

293317
protected switchTab(tab: OrgLensProjectDetailTab): void {

apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,10 +339,23 @@ <h2 class="text-base font-semibold text-gray-900" data-testid="org-projects-no-a
339339
</div>
340340
</td>
341341
<td>
342-
<span class="text-sm font-medium text-gray-900">{{ project.contributors.length }}</span>
342+
<a
343+
[routerLink]="['/org/projects', project.slug]"
344+
[queryParams]="contributorsDrawerQueryParams"
345+
class="rounded text-sm font-medium !text-gray-900 hover:!text-gray-900 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
346+
[attr.aria-label]="project.contributorsAriaLabel"
347+
[attr.data-testid]="'org-projects-contributors-' + project.slug"
348+
>{{ project.contributors.length }}</a
349+
>
343350
</td>
344351
<td>
345-
<span class="text-sm font-medium text-gray-900">{{ project.participants.length }}</span>
352+
<a
353+
[routerLink]="['/org/projects', project.slug]"
354+
class="rounded text-sm font-medium !text-gray-900 hover:!text-gray-900 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
355+
[attr.aria-label]="project.participantsAriaLabel"
356+
[attr.data-testid]="'org-projects-participants-' + project.slug"
357+
>{{ project.participants.length }}</a
358+
>
346359
</td>
347360
<td class="text-right">
348361
<lfx-button

apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
ORG_PROJECTS_ALL_FOUNDATIONS_FILTER,
2626
ORG_PROJECTS_PAGE_SIZE_OPTIONS,
2727
ORG_PROJECTS_SEARCH_MIN_LENGTH,
28+
PD_CONTRIBUTORS_CARD_KEY,
29+
PD_DRAWER_QUERY_PARAM,
2830
VALID_ORG_PROJECTS_SORT_FIELDS,
2931
} from '@lfx-one/shared/constants';
3032
import type {
@@ -105,6 +107,7 @@ export class OrgProjectsComponent {
105107

106108
// Configuration
107109
protected readonly pageSizeOptions = [...ORG_PROJECTS_PAGE_SIZE_OPTIONS];
110+
protected readonly contributorsDrawerQueryParams: Record<string, string> = { [PD_DRAWER_QUERY_PARAM]: PD_CONTRIBUTORS_CARD_KEY };
108111
// Static explanatory hover for the Technical / Ecosystem influence column headers.
109112
protected readonly influenceColumnTooltipHtml = `<ul class="flex list-disc flex-col gap-1.5 pl-4 text-left"><li>Technical influence examines code activities (commits, PRs) while ecosystem influence examines non-code collaboration activities (documentation, committees, meetings, events).</li><li>Comparing our company's share of these activities to the project total indicates greater influence in the project.</li></ul>`;
110113
// Minimal Chart.js line config for the Influence Trend sparkline (no axes, points, legend, or tooltip).
@@ -706,6 +709,10 @@ export class OrgProjectsComponent {
706709
return computed(() => this.buildSortedProjects());
707710
}
708711

712+
private countAriaLabel(count: number, singular: string, projectName: string): string {
713+
return `View ${count} ${count === 1 ? singular : `${singular}s`} for ${projectName}`;
714+
}
715+
709716
// Enrich each sorted project with presentation values so the template only reads properties (no in-template logic).
710717
private initRows(): Signal<OrgProjectsTableRow[]> {
711718
return computed(() =>
@@ -730,6 +737,8 @@ export class OrgProjectsComponent {
730737
trendDeltaTextClass: INFLUENCE_TREND_TEXT_CLASS[project.trend.direction],
731738
trendArrowBadgeClass: INFLUENCE_TREND_ARROW_BADGE_CLASS[project.trend.direction],
732739
healthAriaLabel: this.healthAriaLabel(project),
740+
contributorsAriaLabel: this.countAriaLabel(project.contributors.length, 'contributor', project.name),
741+
participantsAriaLabel: this.countAriaLabel(project.participants.length, 'participant', project.name),
733742
}))
734743
);
735744
}

packages/shared/src/constants/org-lens-project-detail.constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export const PD_VALID_METRICS: ReadonlySet<string> = new Set<OrgLensLeaderboardM
2121
export const PD_DEFAULT_TIME_RANGE: OrgLensLeaderboardTimeRange = '2y';
2222
export const PD_VALID_TIME_RANGES: ReadonlySet<string> = new Set<OrgLensLeaderboardTimeRange>(['1y', '2y', 'all']);
2323

24+
export const PD_DRAWER_QUERY_PARAM = 'card';
25+
export const PD_CONTRIBUTORS_CARD_KEY = 'contributors';
26+
export const PD_VALID_DRAWER_CARD_KEYS: ReadonlySet<string> = new Set<string>([PD_CONTRIBUTORS_CARD_KEY]);
27+
2428
/** Snowflake `time_range_type` value for each UI range toggle. */
2529
export const PD_TIME_RANGE_TYPE: Record<OrgLensLeaderboardTimeRange, string> = {
2630
'1y': 'last_365_days',

packages/shared/src/interfaces/org-lens-projects.interface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ export interface OrgProjectsTableRow extends OrgLensProject {
196196
trendArrowBadgeClass: string;
197197
/** Plain-text health summary (rating + sub-scores) for screen readers / keyboard focus. */
198198
healthAriaLabel: string;
199+
/** Plain-text, count-correlated label for the Contributors count link (screen readers / keyboard focus). */
200+
contributorsAriaLabel: string;
201+
/** Plain-text, count-correlated label for the Participants count link (screen readers / keyboard focus). */
202+
participantsAriaLabel: string;
199203
}
200204

201205
export interface OrgLensProjectRow {

0 commit comments

Comments
 (0)