diff --git a/apps/lfx-one/e2e/org-projects.spec.ts b/apps/lfx-one/e2e/org-projects.spec.ts new file mode 100644 index 000000000..010f62c74 --- /dev/null +++ b/apps/lfx-one/e2e/org-projects.spec.ts @@ -0,0 +1,107 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { expect, Page, test } from '@playwright/test'; + +const ORG_PROJECTS_URL = '/org/projects'; +const DATA_LOAD_TIMEOUT = 30_000; +const MOCK_ACCOUNT_ID = '0014100000Te2QjAAJ'; +const MOCK_UID = '4c46585f-878c-8285-b2e9-2dbfc38ddd9b'; + +test.setTimeout(120_000); + +function skipWhenAuthMissing(page: Page): void { + try { + const { hostname } = new URL(page.url()); + if (hostname === 'auth0.com' || hostname.endsWith('.auth0.com')) { + test.skip(true, 'TEST_USERNAME / TEST_PASSWORD not configured — see global-setup.ts'); + } + } catch { + // Let malformed URLs fail naturally. + } +} + +// The Projects page renders from a client-side demo-data service (no projects API yet), so the only +// stub needed is the personas endpoint to give the org context an account to select. +async function stubOrgContext(page: Page): Promise { + await page.route('**/api/user/personas*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + personas: ['contributor'], + personaProjects: {}, + projects: [], + organizations: [ + { + accountId: MOCK_ACCOUNT_ID, + accountName: 'Red Hat LLC', + accountSlug: 'red-hat-llc', + membershipTier: '', + uid: MOCK_UID, + }, + ], + isRootWriter: false, + }), + }) + ); +} + +async function gotoOrgProjectsPage(page: Page): Promise { + await stubOrgContext(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + skipWhenAuthMissing(page); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.goto(ORG_PROJECTS_URL, { waitUntil: 'domcontentloaded' }); + skipWhenAuthMissing(page); + await expect(page).not.toHaveURL(/auth0\.com/); + + if (!page.url().includes('/org/projects')) { + test.skip(true, 'org-lens-enabled flag appears off — /org/projects redirected away'); + } +} + +test.describe('Org Projects', () => { + test('renders the projects table with demo data', async ({ page }) => { + await gotoOrgProjectsPage(page); + + await expect(page.getByTestId('org-projects-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + await expect(page.getByTestId('org-projects-table')).toBeVisible(); + // Demo dataset always includes Kubernetes (Leading / Leading). + const kubernetesRow = page.getByTestId('org-projects-row-kubernetes'); + await expect(kubernetesRow).toBeVisible(); + await expect(kubernetesRow.getByText('Leading').first()).toBeVisible(); + await expect(page.getByTestId('org-projects-export-csv')).toBeVisible(); + }); + + test('sorts by project name from the column header (persists to the URL)', async ({ page }) => { + await gotoOrgProjectsPage(page); + await expect(page.getByTestId('org-projects-table')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + + await page.getByTestId('org-projects-sort-name').click(); + await expect(page).toHaveURL(/[?&]sort=name/); + }); + + test('opens the workspace dropdown and the add-workspace dialog', async ({ page }) => { + await gotoOrgProjectsPage(page); + await expect(page.getByTestId('org-projects-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + + await page.getByTestId('org-projects-workspace-trigger').click(); + // Each company is seeded with only the default workspace. + await expect(page.getByTestId('org-projects-workspace-option-all-activities')).toBeVisible(); + + await page.getByTestId('org-projects-add-workspace').click(); + await expect(page.getByTestId('org-projects-workspace-dialog')).toBeVisible(); + await expect(page.getByTestId('org-projects-workspace-save')).toBeVisible(); + }); + + test('reveals the LFX Insights health detail on hover', async ({ page }) => { + await gotoOrgProjectsPage(page); + await expect(page.getByTestId('org-projects-row-kubernetes')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT }); + + await page.getByTestId('org-projects-health-kubernetes').hover(); + const popover = page.getByTestId('org-projects-health-popover'); + await expect(popover).toBeVisible(); + await expect(popover.getByRole('link', { name: /LFX Insights/ })).toBeVisible(); + }); +}); diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts index ed79d33d0..a85b2248a 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -112,7 +112,7 @@ export const routes: Routes = [ { path: 'projects', data: { lens: 'org', title: 'Projects', description: 'Projects your organization participates in.', icon: 'fa-light fa-folder' }, - loadComponent: loadOrgPlaceholderPage, + loadComponent: () => import('./modules/dashboards/org/org-projects/org-projects.component').then((m) => m.OrgProjectsComponent), }, { path: 'roi', diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html new file mode 100644 index 000000000..286a5f0f5 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.html @@ -0,0 +1,448 @@ + + + +
+ +
+

+ @if (companyName()) { + Projects — {{ companyName() }} + } @else { + Projects + } +

+

+ The open source projects your organization influences, contributes to, and depends on. +

+
+ + + + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+ @if (error()) { +
+ +

Something went wrong loading your projects.

+ +
+ } @else if (!loading() && totalRecords() === 0) { + + } @else { + + + + + + + + + + + + + + + + + + + + + + + + + Actions + + + + + + + + + + + + + + + + + + + {{ bandLabel(project.technicalInfluence) }} + + + + + + + {{ bandLabel(project.ecosystemInfluence) }} + + + + + + + + + + + {{ project.contributors.length }} + + + {{ project.participants.length }} + + + + + + + + + } +
+
+
+ + + + + + + @if (activeHealthProject(); as project) { +
+
+ Health score + + + LFX Insights + +
+ +

{{ project.description }}

+
+ @for (metric of project.healthMetrics; track metric.label) { +
+
+ {{ metric.label }} + {{ metric.value }} +
+
+
+
+
+ } +
+
+ } +
+ + + +
+
Shared Workspaces
+ @for (ws of workspaces(); track ws.id) { +
+ + +
+ } +
+ +
+
+ + + +
+
+ + +
+
+ @if (editingWorkspace()) { + + } @else { + + } +
+ + +
+
+
+
+ + + +
+
+ + This will be shared across all users in your company. +
+ +
+ + +
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts new file mode 100644 index 000000000..6496d11a0 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-projects/org-projects.component.ts @@ -0,0 +1,615 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { Component, computed, DestroyRef, inject, model, signal, Signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + DEFAULT_ORG_PROJECTS_PAGE_SIZE, + DEFAULT_ORG_PROJECTS_SORT_DIR, + DEFAULT_ORG_PROJECTS_SORT_FIELD, + DEFAULT_ORG_PROJECTS_WORKSPACE_ID, + DEFAULT_ORG_PROJECTS_WORKSPACES, + HEALTH_SCORE_LABELS, + HEALTH_SCORE_SEVERITY, + INFLUENCE_BAND_BAR_FILL_CLASS, + INFLUENCE_BAND_BAR_FILL_CLASS_LIGHT, + INFLUENCE_BAND_LABELS, + INFLUENCE_BAND_RANK, + INFLUENCE_TREND_COLOR, + ORG_PROJECTS_PAGE_SIZE_OPTIONS, + VALID_ORG_PROJECTS_SORT_FIELDS, +} from '@lfx-one/shared/constants'; +import type { + HealthScore, + InfluenceBand, + OrgLensProject, + OrgLensProjectsResponse, + OrgProjectsSignalBar, + OrgProjectsSortField, + OrgProjectsTableRow, + OrgProjectsWorkspace, + OrgProjectsWorkspaceId, + SortDirection, + TagSeverity, +} from '@lfx-one/shared/interfaces'; +import { downloadCsv } from '@lfx-one/shared/utils'; +import { MenuItem } from 'primeng/api'; +import { DialogModule } from 'primeng/dialog'; +import { Popover, PopoverModule } from 'primeng/popover'; +import { TooltipModule } from 'primeng/tooltip'; +import { catchError, finalize, of, switchMap } from 'rxjs'; + +import { AvatarComponent } from '@components/avatar/avatar.component'; +import { ButtonComponent } from '@components/button/button.component'; +import { CardComponent } from '@components/card/card.component'; +import { ChartComponent } from '@components/chart/chart.component'; +import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { MenuComponent } from '@components/menu/menu.component'; +import { MultiSelectComponent } from '@components/multi-select/multi-select.component'; +import { SelectComponent } from '@components/select/select.component'; +import { TableComponent } from '@components/table/table.component'; +import { TagComponent } from '@components/tag/tag.component'; +import { AccountContextService } from '@shared/services/account-context.service'; +import { OrgLensProjectsService } from '@shared/services/org-lens-projects.service'; + +const ALL_FOUNDATIONS = 'all'; + +@Component({ + selector: 'lfx-org-projects', + imports: [ + AvatarComponent, + ButtonComponent, + CardComponent, + ChartComponent, + DialogModule, + EmptyStateComponent, + InputTextComponent, + MenuComponent, + MultiSelectComponent, + PopoverModule, + SelectComponent, + TableComponent, + TagComponent, + TooltipModule, + ], + templateUrl: './org-projects.component.html', +}) +export class OrgProjectsComponent { + // Private injections + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly accountContext = inject(AccountContextService); + private readonly projectsService = inject(OrgLensProjectsService); + /** Pending hide timer for the health popover (lets the cursor cross into the popover). */ + private healthHideTimer: ReturnType | null = null; + /** Stable chart-data cache keyed by project reference — avoids reallocating on every template call. */ + private readonly sparklineCache = new WeakMap(); + + // Configuration + protected readonly pageSizeOptions = [...ORG_PROJECTS_PAGE_SIZE_OPTIONS]; + // Static explanatory hover for the Technical / Ecosystem influence column headers. + protected readonly influenceColumnTooltipHtml = `
  • Technical influence examines code activities (commits, PRs) while ecosystem influence examines non-code collaboration activities (documentation, committees, meetings, events).
  • Comparing our company's share of these activities to the project total indicates greater influence in the project.
`; + // Minimal Chart.js line config for the Influence Trend sparkline (no axes, points, legend, or tooltip). + protected readonly sparklineOptions = { + responsive: true, + maintainAspectRatio: false, + elements: { point: { radius: 0 }, line: { borderWidth: 2, tension: 0.4 } }, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false } }, + }; + + // Forms + protected readonly filterForm = new FormGroup({ + foundation: new FormControl(this.route.snapshot.queryParamMap.get('foundation') ?? ALL_FOUNDATIONS, { nonNullable: true }), + employees: new FormControl(this.readEmployeesFromUrl(), { nonNullable: true }), + }); + /** Name field for the add / rename workspace dialog. */ + protected readonly workspaceForm = new FormGroup({ + name: new FormControl('', { nonNullable: true }), + }); + /** Selected project slugs for the "Add project(s)" dialog. */ + protected readonly addProjectsForm = new FormGroup({ + projects: new FormControl([], { nonNullable: true }), + }); + /** Catalog of projects that can be added to the workspace (with logos for the multi-select). */ + protected readonly addableProjectOptions = this.projectsService.getAddableProjectOptions(); + + // Writable Signals + protected readonly loading = signal(false); + protected readonly error = signal(false); + /** Hidden project slugs keyed by workspace id — hide is workspace-local (client-only; never mutates the catalog). */ + protected readonly hiddenByWorkspace = signal>>({}); + /** Shared workspaces (seeded presets + user-created); editable via the workspace dropdown. */ + protected readonly workspaces = signal([...DEFAULT_ORG_PROJECTS_WORKSPACES]); + /** Workspace being renamed/deleted in the settings dialog; `null` while the dialog adds a new one. */ + protected readonly editingWorkspace = signal(null); + /** Two-way visibility for the workspace add/settings dialog (`[(visible)]`). */ + protected readonly workspaceDialogOpen = model(false); + /** Two-way visibility for the "Add project(s)" dialog (`[(visible)]`). */ + protected readonly addProjectsDialogOpen = model(false); + /** Projects added via the Add Project dialog, keyed by workspace id — add is workspace-local (client-only demo state). */ + protected readonly addedByWorkspace = signal>({}); + /** Bumped to re-trigger the demo fetch from the inline error-retry CTA. */ + private readonly reload = signal(0); + /** Action menu items rebuilt per row when the kebab is opened. */ + protected rowMenuItems: MenuItem[] = []; + + // Computed / toSignal + private readonly queryParamMap = toSignal(this.route.queryParamMap, { initialValue: this.route.snapshot.queryParamMap }); + private readonly formValue = toSignal(this.filterForm.valueChanges, { initialValue: this.filterForm.getRawValue() }); + private readonly response: Signal = this.initResponse(); + + protected readonly companyName = computed(() => this.accountContext.selectedAccount()?.accountName ?? ''); + /** Project whose health detail is shown in the shared hover popover. */ + protected readonly activeHealthProject = signal(null); + + protected readonly sortField = computed(() => { + const raw = this.queryParamMap().get('sort'); + return raw && VALID_ORG_PROJECTS_SORT_FIELDS.has(raw as OrgProjectsSortField) ? (raw as OrgProjectsSortField) : DEFAULT_ORG_PROJECTS_SORT_FIELD; + }); + protected readonly sortDir = computed(() => (this.queryParamMap().get('dir') === 'asc' ? 'asc' : DEFAULT_ORG_PROJECTS_SORT_DIR)); + protected readonly pageSize = computed(() => { + const raw = Number(this.queryParamMap().get('size')); + return ORG_PROJECTS_PAGE_SIZE_OPTIONS.includes(raw) ? raw : DEFAULT_ORG_PROJECTS_PAGE_SIZE; + }); + protected readonly pageFirst = computed(() => { + const page = Math.max(1, Number(this.queryParamMap().get('page')) || 1); + return (page - 1) * this.pageSize(); + }); + + // Active workspace comes from the URL (`?workspace=`), validated against the current workspace list. + protected readonly selectedWorkspaceId = computed(() => { + const list = this.workspaces(); + const raw = this.queryParamMap().get('workspace'); + if (raw && list.some((w) => w.id === raw)) { + return raw; + } + if (list.some((w) => w.id === DEFAULT_ORG_PROJECTS_WORKSPACE_ID)) { + return DEFAULT_ORG_PROJECTS_WORKSPACE_ID; + } + return list[0]?.id ?? DEFAULT_ORG_PROJECTS_WORKSPACE_ID; + }); + protected readonly selectedWorkspaceName = computed(() => this.workspaces().find((w) => w.id === this.selectedWorkspaceId())?.name ?? ''); + protected readonly foundationOptions = this.initFoundationOptions(); + protected readonly employeeOptions = this.initEmployeeOptions(); + + /** Workspace preset + foundation filter applied; shared by the table and the Influence Summary. */ + protected readonly filteredProjects = this.initFilteredProjects(); + /** `filteredProjects` ordered by the active sort (pinned rows float to the top). */ + protected readonly sortedProjects = this.initSortedProjects(); + /** Table rows: sorted projects enriched with precomputed bar geometry + tooltip HTML (keeps logic out of the template). */ + protected readonly rows = this.initRows(); + protected readonly totalRecords = computed(() => this.sortedProjects().length); + + public constructor() { + // Filter changes (foundation / employees) write through to the URL and reset to page 1. + this.filterForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { + foundation: value.foundation === ALL_FOUNDATIONS ? null : value.foundation, + employees: value.employees && value.employees.length ? value.employees.join(',') : null, + page: null, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + + // Clear any pending health-popover hide timer on teardown so it can't fire after destroy. + inject(DestroyRef).onDestroy(() => this.cancelHealthHide()); + } + + // Public methods + public retry(): void { + this.reload.update((n) => n + 1); + } + + // Protected methods + protected toggleSort(field: OrgProjectsSortField): void { + const nextDir: SortDirection = this.sortField() === field && this.sortDir() === 'desc' ? 'asc' : 'desc'; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { + sort: field === DEFAULT_ORG_PROJECTS_SORT_FIELD ? null : field, + dir: nextDir === DEFAULT_ORG_PROJECTS_SORT_DIR ? null : nextDir, + page: null, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected onPage(event: { first?: number; rows?: number }): void { + const rows = event.rows ?? this.pageSize(); + const page = Math.floor((event.first ?? 0) / rows) + 1; + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { page: page <= 1 ? null : page, size: rows === DEFAULT_ORG_PROJECTS_PAGE_SIZE ? null : rows }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected resetFilters(): void { + this.filterForm.reset({ foundation: ALL_FOUNDATIONS, employees: [] }); + this.selectWorkspace(DEFAULT_ORG_PROJECTS_WORKSPACE_ID); + } + + // Active sort column shows a solid blue arrow (LFX self-serve pattern); inactive columns a faint grey double-arrow. + protected sortIcon(field: OrgProjectsSortField): string { + if (this.sortField() !== field) { + return 'fa-light fa-sort text-gray-300'; + } + return this.sortDir() === 'asc' ? 'fa-solid fa-sort-up text-blue-500' : 'fa-solid fa-sort-down text-blue-500'; + } + + protected openAddProjects(): void { + this.addProjectsForm.setValue({ projects: [] }); + this.addProjectsDialogOpen.set(true); + } + + protected confirmAddProjects(): void { + const slugs = this.addProjectsForm.getRawValue().projects; + const ws = this.selectedWorkspaceId(); + const existing = new Set([...(this.response()?.projects ?? []), ...(this.addedByWorkspace()[ws] ?? [])].map((p) => p.slug)); + const additions = this.projectsService.buildAddedProjects(slugs).filter((p) => !existing.has(p.slug)); + if (additions.length) { + this.addedByWorkspace.update((map) => ({ ...map, [ws]: [...(map[ws] ?? []), ...additions] })); + } + this.addProjectsDialogOpen.set(false); + } + + protected selectWorkspace(id: OrgProjectsWorkspaceId): void { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { workspace: id === DEFAULT_ORG_PROJECTS_WORKSPACE_ID ? null : id, page: null }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + protected openAddWorkspace(): void { + this.editingWorkspace.set(null); + this.workspaceForm.setValue({ name: '' }); + this.workspaceDialogOpen.set(true); + } + + protected openWorkspaceSettings(workspace: OrgProjectsWorkspace): void { + this.editingWorkspace.set(workspace); + this.workspaceForm.setValue({ name: workspace.name }); + this.workspaceDialogOpen.set(true); + } + + protected saveWorkspace(): void { + const name = this.workspaceForm.getRawValue().name.trim(); + if (!name) { + return; + } + const editing = this.editingWorkspace(); + if (editing) { + this.workspaces.update((list) => list.map((w) => (w.id === editing.id ? { ...w, name } : w))); + } else { + const id = this.uniqueWorkspaceId(name); + this.workspaces.update((list) => [...list, { id, name }]); + this.selectWorkspace(id); + } + this.workspaceDialogOpen.set(false); + } + + protected deleteWorkspace(): void { + const editing = this.editingWorkspace(); + if (!editing) { + return; + } + // Capture before mutating: once removed, selectedWorkspaceId() already falls back, hiding the stale `?workspace=`. + const wasActive = this.selectedWorkspaceId() === editing.id; + // Re-seed the default if the last workspace is removed so the company is never left with none. + const remaining = this.workspaces().filter((w) => w.id !== editing.id); + const next = remaining.length > 0 ? remaining : [...DEFAULT_ORG_PROJECTS_WORKSPACES]; + this.workspaces.set(next); + if (wasActive) { + this.selectWorkspace(next[0].id); + } + this.workspaceDialogOpen.set(false); + } + + protected openRowMenu(menu: MenuComponent, project: OrgLensProject, event: Event): void { + this.rowMenuItems = this.buildRowMenu(project); + menu.toggle(event); + } + + protected openDetail(project: OrgLensProject): void { + // Project Detail sub-page is delivered in LFXV2-1885; navigation target wired there. + void this.router.navigate([], { relativeTo: this.route, queryParams: { project: project.slug }, queryParamsHandling: 'merge' }); + } + + protected exportCsv(): void { + const rows = this.sortedProjects(); + if (!rows.length) { + return; + } + const header = ['Project', 'Health Score', 'Technical Influence', 'Ecosystem Influence', 'Influence Trend (1y) %', 'Our Contributors', 'Our Participants']; + const body = rows.map((p) => [ + p.name, + HEALTH_SCORE_LABELS[p.health], + INFLUENCE_BAND_LABELS[p.technicalInfluence], + INFLUENCE_BAND_LABELS[p.ecosystemInfluence], + p.trend.deltaPct, + p.contributors.length, + p.participants.length, + ]); + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const slug = this.response()?.orgSlug ?? 'org'; + downloadCsv(`org-lens-projects-${slug}-${date}.csv`, [header, ...body]); + } + + // Template display helpers + protected bandLabel(band: InfluenceBand): string { + return INFLUENCE_BAND_LABELS[band]; + } + // Signal-strength bars for an influence band: filled count = rank (Leading 4 → Silent 1 → Non-LF 0), + // colored by band; remaining bars faded. Non-LF (0 filled) gets a diagonal slash from the template. + // Geometry mirrors the LFX Insights signal-bar icon: 4 evenly spaced, ascending, rounded bars in a 16×16 box. + protected bandBars(band: InfluenceBand): OrgProjectsSignalBar[] { + const heights = [5, 8.3, 11.6, 15]; + const barWidth = 2.6; + const gap = 1.8; + const filled = INFLUENCE_BAND_RANK[band]; + return heights.map((h, i) => ({ + x: i * (barWidth + gap), + y: 16 - h, + w: barWidth, + h, + // Filled bars use the band color; unfilled use a lighter tint of the same color (org dashboard design). + colorClass: i < filled ? INFLUENCE_BAND_BAR_FILL_CLASS[band] : INFLUENCE_BAND_BAR_FILL_CLASS_LIGHT[band], + })); + } + protected openHealth(event: Event, project: OrgLensProject, popover: Popover): void { + this.cancelHealthHide(); + this.activeHealthProject.set(project); + popover.show(event, event.currentTarget as HTMLElement); + } + // Delay hide so the cursor can travel from the cell into the popover (keeps the LFX Insights link clickable). + protected scheduleHealthHide(popover: Popover): void { + this.cancelHealthHide(); + this.healthHideTimer = setTimeout(() => popover.hide(), 200); + } + protected cancelHealthHide(): void { + if (this.healthHideTimer !== null) { + clearTimeout(this.healthHideTimer); + this.healthHideTimer = null; + } + } + protected healthLabel(health: HealthScore): string { + return HEALTH_SCORE_LABELS[health]; + } + protected healthSeverity(health: HealthScore): TagSeverity { + return HEALTH_SCORE_SEVERITY[health]; + } + // Hover tooltip for the Influence Trend sparkline: combined / technical / ecosystem 1y deltas. + protected trendTooltip(project: OrgLensProject): string { + const t = project.trend; + return `
${this.trendTooltipRow('Combined influence', t.deltaPct)}${this.trendTooltipRow('Technical influence', t.technicalDeltaPct)}${this.trendTooltipRow('Ecosystem influence', t.ecosystemDeltaPct)}
`; + } + protected trendTooltipRow(label: string, value: number): string { + const sign = value > 0 ? '+' : ''; + return `
${label}${sign}${value}%
`; + } + protected pctColorClass(value: number): string { + if (value > 1) { + return 'text-emerald-300'; + } + if (value < -1) { + return 'text-red-300'; + } + return 'text-gray-300'; + } + // Plain-text trend summary for screen readers / keyboard focus on the sparkline. + protected trendAriaLabel(project: OrgLensProject): string { + const t = project.trend; + const fmt = (v: number): string => `${v > 0 ? '+' : ''}${v}%`; + return `Influence trend over the past year — combined ${fmt(t.deltaPct)}, technical ${fmt(t.technicalDeltaPct)}, ecosystem ${fmt(t.ecosystemDeltaPct)}.`; + } + // Full health summary (rating + sub-scores) so keyboard/screen-reader users get the popover's content without a mouse. + protected healthAriaLabel(project: OrgLensProject): string { + const metrics = project.healthMetrics.map((m) => `${m.label} ${m.value}`).join(', '); + return `Health: ${HEALTH_SCORE_LABELS[project.health]}. ${metrics}.`; + } + protected sparklineData(project: OrgLensProject): { labels: string[]; datasets: { data: number[]; borderColor: string; fill: boolean }[] } { + const cached = this.sparklineCache.get(project); + if (cached) { + return cached; + } + const data = { + labels: project.trend.series.map((_, i) => String(i)), + datasets: [{ data: project.trend.series, borderColor: INFLUENCE_TREND_COLOR[project.trend.direction], fill: false }], + }; + this.sparklineCache.set(project, data); + return data; + } + + // Private initializers + private initResponse(): Signal { + const account$ = toObservable(computed(() => ({ account: this.accountContext.selectedAccount(), _reload: this.reload() }))); + return toSignal( + account$.pipe( + switchMap(({ account }) => { + // Demo data is not tied to a real org, so it renders even before an org is selected + // (local dev / no impersonation). The real integration will key off `account.uid`. + const uid = account?.uid ?? 'demo-org'; + this.loading.set(true); + this.error.set(false); + return this.projectsService.getProjects(uid, account?.accountName ?? '').pipe( + catchError(() => { + this.error.set(true); + return of(null); + }), + finalize(() => this.loading.set(false)) + ); + }) + ), + { initialValue: null } + ); + } + + private initFoundationOptions(): Signal<{ label: string; value: string }[]> { + return computed(() => { + const projects = this.response()?.projects ?? []; + const bySlug = new Map(); + for (const project of projects) { + bySlug.set(project.foundation.slug, project.foundation.name); + } + const options = [...bySlug.entries()].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)); + return [{ label: 'All Foundations', value: ALL_FOUNDATIONS }, ...options]; + }); + } + + private initEmployeeOptions(): Signal<{ label: string; value: string }[]> { + return computed(() => { + const projects = this.response()?.projects ?? []; + const byId = new Map(); + for (const project of projects) { + for (const person of [...project.maintainers, ...project.contributors, ...project.participants]) { + byId.set(person.id, person.name); + } + } + return [...byId.entries()].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)); + }); + } + + private initFilteredProjects(): Signal { + return computed(() => { + const workspace = this.selectedWorkspaceId(); + const all = [...(this.response()?.projects ?? []), ...(this.addedByWorkspace()[workspace] ?? [])]; + const foundation = this.formValue().foundation ?? ALL_FOUNDATIONS; + // Drop stale/unknown employee ids from the URL so a shared deep link can't filter everything out. + const validEmployeeIds = new Set(this.employeeOptions().map((option) => option.value)); + const employees = (this.formValue().employees ?? []).filter((id) => validEmployeeIds.has(id)); + const hidden = this.hiddenByWorkspace()[workspace] ?? new Set(); + return all + .filter((p) => !hidden.has(p.slug)) + .filter((p) => this.matchesWorkspace(p, workspace)) + .filter((p) => foundation === ALL_FOUNDATIONS || p.foundation.slug === foundation) + .filter((p) => employees.length === 0 || [...p.maintainers, ...p.contributors, ...p.participants].some((person) => employees.includes(person.id))); + }); + } + + private initSortedProjects(): Signal { + return computed(() => { + const projects = [...this.filteredProjects()]; + const field = this.sortField(); + const dir = this.sortDir(); + projects.sort((a, b) => this.compareProjects(a, b, field, dir)); + return projects; + }); + } + + // Enrich each sorted project with presentation values so the template only reads properties (no in-template logic). + private initRows(): Signal { + return computed(() => + this.sortedProjects().map((project) => ({ + ...project, + technicalBars: this.bandBars(project.technicalInfluence), + ecosystemBars: this.bandBars(project.ecosystemInfluence), + trendTooltipHtml: this.trendTooltip(project), + trendAriaLabel: this.trendAriaLabel(project), + healthAriaLabel: this.healthAriaLabel(project), + })) + ); + } + + private compareProjects(a: OrgLensProject, b: OrgLensProject, field: OrgProjectsSortField, dir: SortDirection): number { + const primary = this.compareByField(a, b, field); + const directed = dir === 'asc' ? primary : -primary; + if (directed !== 0) { + return directed; + } + // Tie-break: participant count desc, then project name asc. + const participantTie = b.participants.length - a.participants.length; + return participantTie !== 0 ? participantTie : a.name.localeCompare(b.name); + } + + private compareByField(a: OrgLensProject, b: OrgLensProject, field: OrgProjectsSortField): number { + switch (field) { + case 'name': + return a.name.localeCompare(b.name); + case 'health': + return this.healthRank(a.health) - this.healthRank(b.health); + case 'technicalInfluence': + return INFLUENCE_BAND_RANK[a.technicalInfluence] - INFLUENCE_BAND_RANK[b.technicalInfluence]; + case 'ecosystemInfluence': + return INFLUENCE_BAND_RANK[a.ecosystemInfluence] - INFLUENCE_BAND_RANK[b.ecosystemInfluence]; + case 'influenceTrend': + return a.trend.deltaPct - b.trend.deltaPct; + case 'contributors': + return a.contributors.length - b.contributors.length; + case 'participants': + return a.participants.length - b.participants.length; + default: + return 0; + } + } + + private healthRank(health: HealthScore): number { + if (health === 'excellent') { + return 2; + } + return health === 'healthy' ? 1 : 0; + } + + private matchesWorkspace(project: OrgLensProject, workspace: OrgProjectsWorkspaceId): boolean { + switch (workspace) { + case 'most-active': + // Active = not archived (excludes the demo "Jenkins" archived row with score 0). + return project.influenceScore > 0; + case 'key-projects': + // "Key" = projects we lead or actively contribute to. + return project.technicalInfluence === 'leading' || project.technicalInfluence === 'contributing'; + case 'finos': + case 'cncf': + // Foundation-scoped workspaces match by foundation slug. + return project.foundation.slug === workspace; + case DEFAULT_ORG_PROJECTS_WORKSPACE_ID: + default: + // "All Projects with Activities" (and any custom workspace) shows every project with activity. + return true; + } + } + + private buildRowMenu(project: OrgLensProject): MenuItem[] { + return [{ label: 'Hide project from workspace', icon: 'fa-light fa-eye-slash', command: () => this.hideFromWorkspace(project.slug) }]; + } + + private hideFromWorkspace(slug: string): void { + const ws = this.selectedWorkspaceId(); + this.hiddenByWorkspace.update((map) => ({ ...map, [ws]: new Set(map[ws] ?? []).add(slug) })); + } + + private uniqueWorkspaceId(name: string): string { + const base = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') || 'workspace'; + const existing = new Set(this.workspaces().map((w) => w.id)); + if (!existing.has(base)) { + return base; + } + let suffix = 2; + while (existing.has(`${base}-${suffix}`)) { + suffix += 1; + } + return `${base}-${suffix}`; + } + + private readEmployeesFromUrl(): string[] { + const raw = this.route.snapshot.queryParamMap.get('employees'); + return raw ? raw.split(',').filter(Boolean) : []; + } +} diff --git a/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.html b/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.html index 9fafc7133..889b0463b 100644 --- a/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.html +++ b/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.html @@ -15,13 +15,22 @@ [size]="size()" [styleClass]="styleClass()" class="w-full"> - @if (optionSubLabel(); as subKey) { + @if (optionSubLabel() || optionImage()) { -
- {{ option[optionLabel()] }} - @if (option[subKey]) { - {{ option[subKey] }} +
+ @if (optionImage(); as imgKey) { + @if (option[imgKey]) { + + } } +
+ {{ option[optionLabel()] }} + @if (optionSubLabel(); as subKey) { + @if (option[subKey]) { + {{ option[subKey] }} + } + } +
} diff --git a/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.ts b/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.ts index a3453c7f1..211d81d52 100644 --- a/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.ts +++ b/apps/lfx-one/src/app/shared/components/multi-select/multi-select.component.ts @@ -21,6 +21,9 @@ export class MultiSelectComponent { // list. Selected chips still show only optionLabel. When undefined the // dropdown renders default p-multiSelect items. public readonly optionSubLabel = input(undefined); + // Optional key for a per-option logo/image URL shown before the label in the dropdown list. + // When undefined (and no optionSubLabel) the dropdown renders default p-multiSelect items. + public readonly optionImage = input(undefined); public readonly placeholder = input('Select'); public readonly showToggleAll = input(true); public readonly appendTo = input('body'); diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts new file mode 100644 index 000000000..e906d1739 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.demo-data.ts @@ -0,0 +1,437 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import type { + HealthScore, + InfluenceTrend, + InfluenceTrendDirection, + OrgLensProject, + OrgLensProjectFoundation, + OrgLensProjectPerson, + OrgLensProjectsResponse, + ProjectHealthMetric, +} from '@lfx-one/shared/interfaces'; + +/** + * Demo company data for the Org Lens Projects page (LFXV2-1883 / LFXV2-1884). + * + * Real company-data integration (Snowflake / LFX Insights) is a separate story; until + * then `OrgLensProjectsService` serves these fixtures. They are deliberately varied to + * exercise every band / health / trend state and the Influence Summary empty cases: + * one project has `priorYearScore: 0` (excluded from Most Gains) and one is archived + * with `influenceScore: 0` (excluded from Most Decreases). + */ + +// Person avatar URLs are intentionally empty (initials fallback). Project logos are populated separately from GitHub avatar URLs. +function person(id: string, name: string): OrgLensProjectPerson { + return { id, name, avatarUrl: '' }; +} + +const CNCF: OrgLensProjectFoundation = { slug: 'cncf', name: 'CNCF', logoUrl: '' }; +const LF_AI: OrgLensProjectFoundation = { slug: 'lf-ai-data', name: 'LF AI & Data', logoUrl: '' }; +const LF_NETWORKING: OrgLensProjectFoundation = { slug: 'lf-networking', name: 'LF Networking', logoUrl: '' }; +const OPENSSF: OrgLensProjectFoundation = { slug: 'openssf', name: 'OpenSSF', logoUrl: '' }; +const CD_FOUNDATION: OrgLensProjectFoundation = { slug: 'cd-foundation', name: 'CD Foundation', logoUrl: '' }; + +function trendDirection(deltaPct: number): InfluenceTrendDirection { + if (deltaPct > 1) { + return 'up'; + } + return deltaPct < -1 ? 'down' : 'flat'; +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +// Demo technical/ecosystem deltas are deterministic variations of the combined delta so the +// hover tooltip shows three distinct-but-plausible numbers. Real data supplies all three directly. +function trend(deltaPct: number, series: number[]): InfluenceTrend { + return { + deltaPct, + technicalDeltaPct: round1(deltaPct * 1.15), + ecosystemDeltaPct: round1(deltaPct * 0.8), + direction: trendDirection(deltaPct), + series, + }; +} + +// `description` + `healthMetrics` are injected by getDemoProjectsResponse so the 14 base rows stay terse. +const DEMO_PROJECTS: Omit[] = [ + { + slug: 'kubernetes', + name: 'Kubernetes', + logoUrl: '', + foundation: CNCF, + health: 'excellent', + technicalInfluence: 'leading', + ecosystemInfluence: 'leading', + influenceScore: 92.4, + priorYearScore: 78.2, + trend: trend(18.2, [78, 80, 83, 85, 88, 90, 92]), + maintainers: [ + person('p1', 'Ada Lovelace'), + person('p2', 'Grace Hopper'), + person('p3', 'Alan Turing'), + person('p4', 'Linus Park'), + person('p5', 'Mira Chen'), + ], + contributors: [ + person('p6', 'Tom Reyes'), + person('p7', 'Nina Patel'), + person('p8', 'Omar Diaz'), + person('p9', 'Sara Kim'), + person('p10', 'Wei Zhang'), + person('p11', 'Eve Stone'), + ], + participants: [person('p12', 'Raj Singh'), person('p13', 'Lena Fox')], + commits1y: 48210, + changeDriver: { label: '+3 maintainers', direction: 'up' }, + }, + { + slug: 'prometheus', + name: 'Prometheus', + logoUrl: '', + foundation: CNCF, + health: 'excellent', + technicalInfluence: 'leading', + ecosystemInfluence: 'contributing', + influenceScore: 84.1, + priorYearScore: 76.5, + trend: trend(9.9, [76, 77, 79, 80, 82, 83, 84]), + maintainers: [person('p14', 'Carlos Mota'), person('p15', 'Yuki Tanaka')], + contributors: [person('p16', 'Dana Cole'), person('p17', 'Ivan Petrov'), person('p18', 'Maya Rao')], + participants: [person('p19', 'Joel Park')], + commits1y: 12880, + changeDriver: { label: '+38% commits', direction: 'up' }, + }, + { + slug: 'envoy', + name: 'Envoy', + logoUrl: '', + foundation: CNCF, + health: 'healthy', + technicalInfluence: 'leading', + ecosystemInfluence: 'contributing', + influenceScore: 80.7, + priorYearScore: 71.0, + trend: trend(13.7, [71, 72, 74, 76, 78, 79, 81]), + maintainers: [person('p20', 'Priya Nair'), person('p21', 'Hugo Blanc'), person('p22', 'Sven Olsen')], + contributors: [person('p23', 'Lily Brooks'), person('p24', 'Kofi Mensah')], + participants: [person('p25', 'Anya Volkov'), person('p26', 'Diego Ruiz'), person('p27', 'Fatima Zahra')], + commits1y: 9340, + changeDriver: { label: '+2 board seats', direction: 'up' }, + }, + { + slug: 'opentelemetry', + name: 'OpenTelemetry', + logoUrl: '', + foundation: CNCF, + health: 'excellent', + technicalInfluence: 'contributing', + ecosystemInfluence: 'leading', + influenceScore: 79.3, + priorYearScore: 58.9, + trend: trend(34.6, [58, 62, 66, 70, 74, 77, 79]), + maintainers: [person('p28', 'Reza Karimi'), person('p29', 'Bo Andersson')], + contributors: [person('p30', 'Tara Singh'), person('p31', 'Max Weber'), person('p32', 'Iris Lin'), person('p33', 'Pavel Novak')], + participants: [person('p34', 'Noor Ali')], + commits1y: 15600, + changeDriver: { label: '+52% commits', direction: 'up' }, + }, + { + slug: 'argo', + name: 'Argo', + logoUrl: '', + foundation: CNCF, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'contributing', + influenceScore: 68.5, + priorYearScore: 64.2, + trend: trend(6.7, [64, 65, 65, 66, 67, 68, 69]), + maintainers: [person('p35', 'Greta Hahn')], + contributors: [person('p36', 'Sam Otieno'), person('p37', 'Bianca Russo')], + participants: [person('p38', 'Kenji Mori'), person('p39', 'Aisha Bello')], + commits1y: 6120, + changeDriver: { label: '+1 maintainer', direction: 'up' }, + }, + { + slug: 'pytorch', + name: 'PyTorch', + logoUrl: '', + foundation: LF_AI, + health: 'excellent', + technicalInfluence: 'leading', + ecosystemInfluence: 'leading', + influenceScore: 88.0, + priorYearScore: 83.4, + trend: trend(5.5, [83, 84, 85, 86, 86, 87, 88]), + maintainers: [person('p40', 'Hannah Frost'), person('p41', 'Leo Marsh'), person('p42', 'Devi Suresh')], + contributors: [person('p43', 'Quinn Hale'), person('p44', 'Ravi Iyer'), person('p45', 'Selin Aydin'), person('p46', 'Tobias Weiss')], + participants: [person('p47', 'Mei Lin')], + commits1y: 21030, + changeDriver: { label: '+24% commits', direction: 'up' }, + }, + { + slug: 'onnx', + name: 'ONNX', + logoUrl: '', + foundation: LF_AI, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'participating', + influenceScore: 54.2, + priorYearScore: 57.8, + trend: trend(-6.2, [58, 57, 57, 56, 55, 55, 54]), + maintainers: [person('p48', 'Owen Clarke')], + contributors: [person('p49', 'Petra Vogel'), person('p50', 'Hassan Najjar')], + participants: [person('p51', 'Rita Costa')], + commits1y: 3420, + changeDriver: { label: '-22% commits', direction: 'down' }, + }, + { + slug: 'onap', + name: 'ONAP', + logoUrl: '', + foundation: LF_NETWORKING, + health: 'at-risk', + technicalInfluence: 'participating', + ecosystemInfluence: 'participating', + influenceScore: 41.6, + priorYearScore: 51.4, + trend: trend(-19.1, [51, 49, 48, 46, 44, 43, 42]), + maintainers: [person('p52', 'Gabe Lewis')], + contributors: [person('p53', 'Hana Suzuki')], + participants: [person('p54', 'Igor Pavlov'), person('p55', 'Joy Adeyemi')], + commits1y: 1880, + changeDriver: { label: '-1 maintainer', direction: 'down' }, + }, + { + slug: 'fd-io', + name: 'FD.io', + logoUrl: '', + foundation: LF_NETWORKING, + health: 'at-risk', + technicalInfluence: 'silent', + ecosystemInfluence: 'non-lf', + influenceScore: 33.9, + priorYearScore: 42.7, + trend: trend(-20.6, [43, 41, 40, 38, 36, 35, 34]), + maintainers: [], + contributors: [person('p56', 'Karl Schmidt')], + participants: [person('p57', 'Lucia Moreno')], + commits1y: 940, + changeDriver: { label: '-31% commits', direction: 'down' }, + }, + { + slug: 'sigstore', + name: 'Sigstore', + logoUrl: '', + foundation: OPENSSF, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'contributing', + influenceScore: 62.8, + // No 12-month baseline (graduated mid-window) — excluded from Most Gains. + priorYearScore: 0, + trend: trend(0, [60, 61, 62, 62, 62, 63, 63]), + maintainers: [person('p58', 'Nadia Haddad'), person('p59', 'Oskar Lind')], + contributors: [person('p60', 'Pia Berg'), person('p61', 'Rohan Mehta')], + participants: [], + commits1y: 4510, + changeDriver: { label: 'New baseline', direction: 'flat' }, + }, + { + slug: 'in-toto', + name: 'in-toto', + logoUrl: '', + foundation: OPENSSF, + health: 'healthy', + technicalInfluence: 'participating', + ecosystemInfluence: 'silent', + influenceScore: 47.3, + priorYearScore: 44.9, + trend: trend(5.3, [44, 45, 45, 46, 46, 47, 47]), + maintainers: [person('p62', 'Sami Rahimi')], + contributors: [person('p63', 'Tess Howell')], + participants: [person('p64', 'Umar Faruk'), person('p65', 'Vera Klein'), person('p66', 'Will Tanaka')], + commits1y: 2210, + changeDriver: { label: '+12% commits', direction: 'up' }, + }, + { + slug: 'tekton', + name: 'Tekton', + logoUrl: '', + foundation: CD_FOUNDATION, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'participating', + influenceScore: 58.1, + priorYearScore: 60.9, + trend: trend(-4.6, [61, 60, 60, 59, 59, 58, 58]), + maintainers: [person('p67', 'Xena Pope')], + contributors: [person('p68', 'Yusuf Demir'), person('p69', 'Zoe Walsh')], + participants: [person('p70', 'Arman Yilmaz')], + commits1y: 3990, + changeDriver: { label: '-1 board seat', direction: 'down' }, + }, + { + slug: 'jenkins', + name: 'Jenkins', + logoUrl: '', + foundation: CD_FOUNDATION, + health: 'at-risk', + technicalInfluence: 'silent', + ecosystemInfluence: 'non-lf', + // Archived in our workspace — excluded from Most Decreases (current score 0). + influenceScore: 0, + priorYearScore: 38.2, + trend: trend(-42.0, [38, 30, 24, 16, 9, 4, 0]), + maintainers: [], + contributors: [], + participants: [person('p71', 'Bella North')], + commits1y: 120, + changeDriver: { label: 'Archived', direction: 'down' }, + }, + { + slug: 'spiffe-spire', + name: 'SPIFFE / SPIRE', + logoUrl: '', + foundation: CNCF, + health: 'healthy', + technicalInfluence: 'contributing', + ecosystemInfluence: 'contributing', + influenceScore: 66.0, + priorYearScore: 55.1, + trend: trend(19.8, [55, 57, 59, 61, 63, 65, 66]), + maintainers: [person('p72', 'Cody Frank'), person('p73', 'Dilara Kaya')], + contributors: [person('p74', 'Emil Larsson'), person('p75', 'Farah Saleh'), person('p76', 'Gus Romero')], + participants: [person('p77', 'Hye-jin Park'), person('p78', 'Ido Cohen')], + commits1y: 5230, + changeDriver: { label: '+2 maintainers', direction: 'up' }, + }, +]; + +// Demo project logos sourced from each project's public GitHub org avatar (CDN-served, stable). +const LOGO_BY_SLUG: Record = { + kubernetes: 'https://github.com/kubernetes.png?size=80', + prometheus: 'https://github.com/prometheus.png?size=80', + envoy: 'https://github.com/envoyproxy.png?size=80', + opentelemetry: 'https://github.com/open-telemetry.png?size=80', + argo: 'https://github.com/argoproj.png?size=80', + pytorch: 'https://github.com/pytorch.png?size=80', + onnx: 'https://github.com/onnx.png?size=80', + onap: 'https://github.com/onap.png?size=80', + 'fd-io': 'https://github.com/FDio.png?size=80', + sigstore: 'https://github.com/sigstore.png?size=80', + 'in-toto': 'https://github.com/in-toto.png?size=80', + tekton: 'https://github.com/tektoncd.png?size=80', + jenkins: 'https://github.com/jenkinsci.png?size=80', + 'spiffe-spire': 'https://github.com/spiffe.png?size=80', +}; + +// Short descriptions shown in the health-detail popover (generic fallback covers any unmapped slug). +const DESCRIPTION_BY_SLUG: Record = { + kubernetes: 'Production-grade container orchestration for automating deployment, scaling, and management of containerized applications.', + prometheus: 'An open-source systems monitoring and alerting toolkit with a dimensional data model and a powerful query language.', + envoy: 'A high-performance open source edge and service proxy designed for cloud-native applications.', + opentelemetry: 'A collection of APIs, SDKs, and tools for instrumenting, generating, and collecting telemetry data.', + argo: 'Kubernetes-native workflow engine and GitOps continuous delivery tooling.', + pytorch: 'An open source machine learning framework that accelerates the path from research prototyping to production.', + onnx: 'An open standard for representing machine learning models, enabling interoperability across frameworks.', + onap: 'Open Network Automation Platform for orchestrating physical and virtual network functions.', + 'fd-io': 'Fast Data I/O: a high-performance IO services framework for dynamic networking workloads.', + sigstore: 'Free software signing and transparency to make the software supply chain more secure.', + 'in-toto': 'A framework to cryptographically secure the integrity of software supply chains.', + tekton: 'A flexible Kubernetes-native framework for building CI/CD systems.', + jenkins: 'The leading open source automation server for building, testing, and deploying software.', + 'spiffe-spire': 'A universal identity control plane that issues cryptographic workload identities to distributed systems.', +}; + +// Deterministic CHAOSS-style sub-scores so the health popover is stable across reloads. Real data supplies these. +function buildHealthMetrics(project: Omit): ProjectHealthMetric[] { + const baseByHealth: Record = { excellent: 84, healthy: 64, 'at-risk': 42 }; + const base = baseByHealth[project.health]; + const seed = project.slug.length + Math.round(project.influenceScore); + const score = (offset: number): number => Math.max(22, Math.min(98, base + ((seed * (offset + 3)) % 26) - 10)); + return [ + { label: 'Contributors', value: score(1) }, + { label: 'Popularity', value: score(2) }, + { label: 'Development', value: score(3) }, + { label: 'Security', value: score(4) }, + ]; +} + +/** Demo response for a single org. The org slug/name flow into the CSV export filename + header. */ +export function getDemoProjectsResponse(orgUid: string, orgName: string): OrgLensProjectsResponse { + // Sanitize first, then fall back to orgUid — covers names that sanitize to an empty slug (e.g. punctuation-only). + const orgSlug = + orgName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') || orgUid; + return { + orgSlug, + orgName: orgName || 'Your organization', + // Static demo build timestamp (~2h ago). + dataUpdatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + projects: DEMO_PROJECTS.map((project) => ({ + ...project, + logoUrl: LOGO_BY_SLUG[project.slug] ?? project.logoUrl, + description: DESCRIPTION_BY_SLUG[project.slug] ?? `${project.name} is an open source project in the ${project.foundation.name} ecosystem.`, + healthMetrics: buildHealthMetrics(project), + })), + }; +} + +// Demo catalog of projects a user can add to a workspace (distinct from the seeded DEMO_PROJECTS). +const ADDABLE_PROJECTS: readonly { slug: string; name: string; foundation: OrgLensProjectFoundation; org: string }[] = [ + { slug: 'cilium', name: 'Cilium', foundation: CNCF, org: 'cilium' }, + { slug: 'istio', name: 'Istio', foundation: CNCF, org: 'istio' }, + { slug: 'linkerd', name: 'Linkerd', foundation: CNCF, org: 'linkerd' }, + { slug: 'helm', name: 'Helm', foundation: CNCF, org: 'helm' }, + { slug: 'flux', name: 'Flux', foundation: CNCF, org: 'fluxcd' }, + { slug: 'vitess', name: 'Vitess', foundation: CNCF, org: 'vitessio' }, + { slug: 'etcd', name: 'etcd', foundation: CNCF, org: 'etcd-io' }, + { slug: 'coredns', name: 'CoreDNS', foundation: CNCF, org: 'coredns' }, + { slug: 'keda', name: 'KEDA', foundation: CNCF, org: 'kedacore' }, + { slug: 'dapr', name: 'Dapr', foundation: CNCF, org: 'dapr' }, + { slug: 'backstage', name: 'Backstage', foundation: CNCF, org: 'backstage' }, + { slug: 'kubeflow', name: 'Kubeflow', foundation: LF_AI, org: 'kubeflow' }, +]; + +function addableLogo(org: string): string { + return `https://github.com/${org}.png?size=80`; +} + +/** Options for the "Add project(s)" multi-select (value=slug, label=name, logoUrl for the option icon). */ +export function getAddableProjectOptions(): { value: string; label: string; logoUrl: string }[] { + return ADDABLE_PROJECTS.map((p) => ({ value: p.slug, label: p.name, logoUrl: addableLogo(p.org) })); +} + +/** Build full demo project rows for the given slugs (newly added → no org activity yet). */ +export function buildAddedProjects(slugs: readonly string[]): OrgLensProject[] { + return ADDABLE_PROJECTS.filter((p) => slugs.includes(p.slug)).map((p) => { + const base: Omit = { + slug: p.slug, + name: p.name, + logoUrl: addableLogo(p.org), + foundation: p.foundation, + health: 'healthy', + technicalInfluence: 'participating', + ecosystemInfluence: 'silent', + influenceScore: 12, + priorYearScore: 12, + trend: trend(0, [12, 12, 12, 12, 12, 12, 12]), + maintainers: [], + contributors: [], + participants: [], + commits1y: 0, + changeDriver: { label: 'Newly added', direction: 'flat' }, + }; + return { ...base, description: `${p.name} was recently added to this workspace.`, healthMetrics: buildHealthMetrics(base) }; + }); +} diff --git a/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts b/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts new file mode 100644 index 000000000..acc3049e9 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-lens-projects.service.ts @@ -0,0 +1,40 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Generated with [Claude Code](https://claude.ai/code) + +import { Injectable } from '@angular/core'; +import type { OrgLensProject, OrgLensProjectsResponse } from '@lfx-one/shared/interfaces'; +import { delay, Observable, of } from 'rxjs'; + +import { buildAddedProjects, getAddableProjectOptions, getDemoProjectsResponse } from './org-lens-projects.demo-data'; + +/** Simulated network latency so the page exercises its loading skeletons. */ +const DEMO_LATENCY_MS = 450; + +/** + * Data seam for the Org Lens Projects page (LFXV2-1883 / LFXV2-1884). + * + * Currently returns demo company fixtures. Wiring the real Snowflake / LFX Insights + * backend (a separate story) only replaces this method body with an `HttpClient` call + * to `/api/orgs/:orgUid/lens/projects` — the response shape and every consumer stay the + * same. See `OrgLensTrainingService` for the eventual HTTP pattern. + */ +@Injectable({ + providedIn: 'root', +}) +export class OrgLensProjectsService { + public getProjects(orgUid: string, orgName: string): Observable { + return of(getDemoProjectsResponse(orgUid, orgName)).pipe(delay(DEMO_LATENCY_MS)); + } + + /** Catalog of projects that can be added to a workspace (`{ value, label, logoUrl }` for the multi-select). */ + public getAddableProjectOptions(): { value: string; label: string; logoUrl: string }[] { + return getAddableProjectOptions(); + } + + /** Build full project rows for the given catalog slugs (used when the user adds projects to a workspace). */ + public buildAddedProjects(slugs: readonly string[]): OrgLensProject[] { + return buildAddedProjects(slugs); + } +} diff --git a/apps/lfx-one/src/styles.scss b/apps/lfx-one/src/styles.scss index c9f4e9403..850ee6949 100644 --- a/apps/lfx-one/src/styles.scss +++ b/apps/lfx-one/src/styles.scss @@ -360,3 +360,22 @@ html { } } } + +// Org Lens projects — explanatory column tooltips need more width than the default. +// Tooltips are appended to , so they must be styled globally (component/Tailwind-scoped styles don't reach them). +// PrimeNG sets `width: fit-content` inline on the tooltip container, which can shrink it below the text max-width; +// a `min-width` floor on the container (min-width coexists with the inline width) forces a readable width, and +// !important wins over both PrimeNG's layered theme and its inline style. +.p-tooltip.lfx-tooltip-wide { + min-width: 26rem !important; + max-width: 40rem !important; +} + +.p-tooltip.lfx-tooltip-wide .p-tooltip-text { + max-width: 40rem !important; + white-space: normal !important; +} + +.p-tooltip.lfx-tooltip-nowrap .p-tooltip-text { + max-width: none !important; +} diff --git a/apps/lfx-one/tailwind.config.js b/apps/lfx-one/tailwind.config.js index 488cfb055..ac35d87a2 100644 --- a/apps/lfx-one/tailwind.config.js +++ b/apps/lfx-one/tailwind.config.js @@ -24,6 +24,18 @@ export default { 'text-amber-500', 'text-purple-500', 'text-gray-500', + // Org Lens projects — influence band signal-strength bars (classes defined in @lfx-one/shared, not scanned here) + 'fill-emerald-500', + 'fill-blue-500', + 'fill-amber-500', + 'fill-red-500', + 'fill-gray-400', + 'fill-gray-200', + // Lighter tints for the unfilled signal bars + 'fill-emerald-200', + 'fill-blue-200', + 'fill-amber-200', + 'fill-red-200', ], theme: { container: { diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 77b64edef..655565e8e 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -52,6 +52,7 @@ export * from './transaction.constants'; export * from './rewards.constants'; export * from './regex.constants'; export * from './org-lens.constants'; +export * from './org-lens-projects.constants'; export * from './org-memberships.constants'; export * from './feature-flags.constants'; export * from './org-selector.constants'; diff --git a/packages/shared/src/constants/org-lens-projects.constants.ts b/packages/shared/src/constants/org-lens-projects.constants.ts new file mode 100644 index 000000000..2ece22c8a --- /dev/null +++ b/packages/shared/src/constants/org-lens-projects.constants.ts @@ -0,0 +1,99 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import type { + HealthScore, + InfluenceBand, + InfluenceTrendDirection, + OrgProjectsSortField, + OrgProjectsWorkspace, + OrgProjectsWorkspaceId, + SortDirection, + TagSeverity, +} from '../interfaces'; +import { lfxColors } from './colors.constants'; + +/** The default workspace for every company: all projects with any activity. */ +export const DEFAULT_ORG_PROJECTS_WORKSPACE_ID: OrgProjectsWorkspaceId = 'all-activities'; + +// Every company starts with only the default workspace; users add/rename/delete their own on top. +export const DEFAULT_ORG_PROJECTS_WORKSPACES: ReadonlyArray = [ + { id: DEFAULT_ORG_PROJECTS_WORKSPACE_ID, name: 'All Projects with Activities' }, +]; + +/** Display labels for influence bands. */ +export const INFLUENCE_BAND_LABELS: Record = { + leading: 'Leading', + contributing: 'Contributing', + participating: 'Participating', + silent: 'Silent', + 'non-lf': 'Non-LF Project', +}; + +/** Sparkline / delta color per trend direction (brand scale values; never hard-coded hex). */ +export const INFLUENCE_TREND_COLOR: Record = { + up: lfxColors.emerald[500], + down: lfxColors.red[500], + flat: lfxColors.gray[400], +}; + +/** Sort rank for influence bands (strongest highest); also the number of filled signal bars (0–4). */ +export const INFLUENCE_BAND_RANK: Record = { + leading: 4, + contributing: 3, + participating: 2, + silent: 1, + 'non-lf': 0, +}; + +/** SVG fill class per influence band for the signal-strength bars icon (filled bars = rank; Non-LF has 0 + a slash). */ +export const INFLUENCE_BAND_BAR_FILL_CLASS: Record = { + leading: 'fill-emerald-500', + contributing: 'fill-blue-500', + participating: 'fill-amber-500', + silent: 'fill-red-500', + 'non-lf': 'fill-gray-400', +}; + +/** Lighter fill for the unfilled signal bars — a tint of the band color (per the org dashboard design). */ +export const INFLUENCE_BAND_BAR_FILL_CLASS_LIGHT: Record = { + leading: 'fill-emerald-200', + contributing: 'fill-blue-200', + participating: 'fill-amber-200', + silent: 'fill-red-200', + 'non-lf': 'fill-gray-200', +}; + +/** Display labels for health scores. */ +export const HEALTH_SCORE_LABELS: Record = { + excellent: 'Excellent', + healthy: 'Healthy', + 'at-risk': 'At Risk', +}; + +/** Tag/badge severity per health score (drives health-badge color). */ +export const HEALTH_SCORE_SEVERITY: Record = { + excellent: 'success', + healthy: 'info', + 'at-risk': 'danger', +}; + +/** Projects-table page sizes; 25 is the default. */ +export const ORG_PROJECTS_PAGE_SIZE_OPTIONS: readonly number[] = [10, 25, 50]; + +export const DEFAULT_ORG_PROJECTS_PAGE_SIZE = 25; + +/** Default Projects-table sort: Contributors descending (tie-break participants desc, then name). */ +export const DEFAULT_ORG_PROJECTS_SORT_FIELD: OrgProjectsSortField = 'contributors'; + +export const DEFAULT_ORG_PROJECTS_SORT_DIR: SortDirection = 'desc'; + +export const VALID_ORG_PROJECTS_SORT_FIELDS = new Set([ + 'name', + 'health', + 'technicalInfluence', + 'ecosystemInfluence', + 'influenceTrend', + 'contributors', + 'participants', +]); diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index bad12188d..09c04e1fb 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -75,6 +75,9 @@ export * from './account.interface'; // Org Lens (per-account TLF membership tier + cdev org mapping) interfaces export * from './org-lens.interface'; +// Org Lens Projects page (influence + health) interfaces +export * from './org-lens-projects.interface'; + // Mailing list interfaces export * from './mailing-list.interface'; diff --git a/packages/shared/src/interfaces/org-lens-projects.interface.ts b/packages/shared/src/interfaces/org-lens-projects.interface.ts new file mode 100644 index 000000000..3e96ec9d1 --- /dev/null +++ b/packages/shared/src/interfaces/org-lens-projects.interface.ts @@ -0,0 +1,165 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Org Lens — Projects page contracts (LFXV2-1883 / LFXV2-1884). + * + * The wire contracts (OrgLensProject, OrgLensProjectsResponse, etc.) are what the API returns; + * the current implementation is fed by a demo-data fixture through `OrgLensProjectsService`, and the + * live Snowflake / LFX Insights integration (a separate story) will populate the same shapes without + * any component changes. The "Client-only view models" section at the bottom is NOT on the wire — + * it is built in the component and must never be populated from API data. + */ + +/** Influence band per the markup-mu methodology (Boysel et al.). Declared strongest → weakest. */ +export type InfluenceBand = 'leading' | 'contributing' | 'participating' | 'silent' | 'non-lf'; + +/** CHAOSS-derived project health classification (via LFX Insights). */ +export type HealthScore = 'excellent' | 'healthy' | 'at-risk'; + +/** Direction of a one-year influence trend, used for color-coding the sparkline + delta. */ +export type InfluenceTrendDirection = 'up' | 'down' | 'flat'; + +/** A person (employee) associated with a project in a maintainer/contributor/participant role. */ +export interface OrgLensProjectPerson { + /** Stable identifier for the person. */ + id: string; + /** Display name shown on avatar hover. */ + name: string; + /** Avatar image URL; empty string falls back to initials. */ + avatarUrl: string; +} + +/** Foundation a project belongs to (logo + name pill). */ +export interface OrgLensProjectFoundation { + /** URL-safe foundation slug, used by the `?foundation=` filter. */ + slug: string; + /** Display name (e.g. "CNCF"). */ + name: string; + /** Foundation logo URL; empty string falls back to a generic glyph. */ + logoUrl: string; +} + +/** Rolling one-year influence trend: a sparkline series plus the headline percent delta. */ +export interface InfluenceTrend { + /** Percent change of the combined influence score, rolling 365 vs prior 365. */ + deltaPct: number; + /** Percent change of the technical influence score, rolling 365 vs prior 365. */ + technicalDeltaPct: number; + /** Percent change of the ecosystem influence score, rolling 365 vs prior 365. */ + ecosystemDeltaPct: number; + /** Direction bucket derived from `deltaPct` (drives green/red/neutral styling). */ + direction: InfluenceTrendDirection; + /** Ordered score samples (oldest → newest) for the sparkline. */ + series: number[]; +} + +/** The single largest signal contributing to a project's influence delta. */ +export interface ChangeDriver { + /** Human-readable driver label (e.g. "+3 maintainers", "-22% commits"). */ + label: string; + /** Whether the driver pushed influence up or down. */ + direction: InfluenceTrendDirection; +} + +/** A single project row in the Org Lens Projects table. */ +export interface OrgLensProject { + /** URL-safe project slug (links to Project Detail). */ + slug: string; + /** Display name. */ + name: string; + /** Project logo URL; empty string falls back to initials. */ + logoUrl: string; + /** Owning foundation. */ + foundation: OrgLensProjectFoundation; + /** CHAOSS health classification. */ + health: HealthScore; + /** Technical influence band. */ + technicalInfluence: InfluenceBand; + /** Ecosystem influence band. */ + ecosystemInfluence: InfluenceBand; + /** Current combined influence score (markup-mu). */ + influenceScore: number; + /** Combined influence score at T-365; `0` means no baseline (excluded from Gains). */ + priorYearScore: number; + /** One-year influence trend. */ + trend: InfluenceTrend; + /** Employees with maintainer roles on the project. */ + maintainers: OrgLensProjectPerson[]; + /** Employees with contributor roles on the project. */ + contributors: OrgLensProjectPerson[]; + /** Employees with participant roles on the project. */ + participants: OrgLensProjectPerson[]; + /** Commit count over the trailing 12 months. */ + commits1y: number; + /** Largest single driver of the influence delta (used by Influence Summary cards). */ + changeDriver: ChangeDriver; + /** Short project description shown in the health-detail popover. */ + description: string; + /** CHAOSS-style health sub-scores (0–100) shown in the health-detail popover. */ + healthMetrics: ProjectHealthMetric[]; +} + +/** A single CHAOSS health sub-score (0–100) shown in the health-detail popover. */ +export interface ProjectHealthMetric { + /** Metric name, e.g. `Contributors`, `Popularity`, `Development`, `Security`. */ + label: string; + /** Score on a 0–100 scale, rendered as a progress bar. */ + value: number; +} + +/** Top-level response for the Org Lens Projects page. */ +export interface OrgLensProjectsResponse { + /** URL-safe org slug, used in the CSV export filename. */ + orgSlug: string; + /** Display name of the organization. */ + orgName: string; + /** ISO timestamp of the LFX Insights data build, rendered as the freshness label. */ + dataUpdatedAt: string; + /** All projects the organization is associated with. */ + projects: OrgLensProject[]; +} + +/** Workspace identifier (`?workspace=`). The default uses a stable slug; user-created workspaces get generated slugs. */ +export type OrgProjectsWorkspaceId = string; + +/** A saved Org Lens workspace (filter preset). Each company is seeded with the default; users add/rename/delete their own. */ +export interface OrgProjectsWorkspace { + id: OrgProjectsWorkspaceId; + name: string; +} + +/** Sortable Projects-table column keys (`?sort=`). */ +export type OrgProjectsSortField = 'name' | 'health' | 'technicalInfluence' | 'ecosystemInfluence' | 'influenceTrend' | 'contributors' | 'participants'; + +/** Sort direction (`?dir=`). */ +export type SortDirection = 'asc' | 'desc'; + +/* + * ── Client-only view models (NOT API wire contracts) ───────────────────────── + * Built in the component off the wire types above; never populated from API data. + * `trendTooltipHtml` is component-authored markup rendered with `[escape]="false"`, + * so it must never be sourced from the wire — keep these types out of any API mapping. + */ + +/** A single rounded bar in the influence signal-strength icon (precomputed for the table view-model). */ +export interface OrgProjectsSignalBar { + x: number; + y: number; + w: number; + h: number; + /** Tailwind `fill-*` class for the bar. */ + colorClass: string; +} + +/** Projects-table row: the project plus presentation values precomputed off the template hot path. */ +export interface OrgProjectsTableRow extends OrgLensProject { + technicalBars: OrgProjectsSignalBar[]; + ecosystemBars: OrgProjectsSignalBar[]; + /** Pre-rendered HTML for the Influence Trend hover tooltip. */ + trendTooltipHtml: string; + /** Plain-text trend summary for screen readers / keyboard focus. */ + trendAriaLabel: string; + /** Plain-text health summary (rating + sub-scores) for screen readers / keyboard focus. */ + healthAriaLabel: string; +} diff --git a/packages/shared/src/utils/file.utils.ts b/packages/shared/src/utils/file.utils.ts index e8c3c274b..0a05456cd 100644 --- a/packages/shared/src/utils/file.utils.ts +++ b/packages/shared/src/utils/file.utils.ts @@ -282,3 +282,49 @@ export function sanitizeFilename(filename: string, maxLength: number = 255): str return sanitized; } + +/** + * Escape a single CSV cell per RFC 4180 and neutralize formula-injection prefixes. + * Cells starting with =, +, -, @, TAB, or CR are prefixed with a leading apostrophe. + * @param value - Raw cell value + * @returns CSV-safe cell string + */ +function escapeCsvCell(value: string | number): string { + const raw = String(value ?? ''); + // Only string cells can carry a formula payload; leave numbers numeric (prefixing makes Excel treat them as text). + const safe = typeof value === 'string' && /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; + return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} + +/** + * Build an RFC 4180 CSV string from a matrix of rows (first row is typically the header). + * @param rows - Array of rows, each an array of cell values + * @returns CSV text with CRLF line endings + */ +export function rowsToCsv(rows: ReadonlyArray>): string { + return rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n'); +} + +/** + * Trigger a client-side CSV file download in the browser. No-op during SSR (no `document`). + * Prepends a UTF-8 BOM so Excel detects the encoding. + * @param filename - Download filename (e.g. "report.csv") + * @param rows - Array of rows, each an array of cell values + */ +export function downloadCsv(filename: string, rows: ReadonlyArray>): void { + // Browser-only: bail unless the full DOM + Blob URL APIs exist (a partial SSR DOM may lack createObjectURL). + if (typeof document === 'undefined' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + return; + } + + const blob = new Blob(['\ufeff' + rowsToCsv(rows)], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = sanitizeFilename(filename); + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +}