From e69ffadb4bf0df403f6a6b9a7c3378b1c20bf922 Mon Sep 17 00:00:00 2001 From: Saravana Kumar Date: Sat, 30 May 2026 20:27:27 +0000 Subject: [PATCH] feat(tracker-web): sync admin product context --- dashboards/tracker-web/e2e/tracker.spec.ts | 6 ++ .../src/__tests__/product-context.test.tsx | 76 +++++++++++++++++++ .../src/app/dashboard/board/page.tsx | 6 +- .../src/app/dashboard/items/page.tsx | 6 +- .../src/app/dashboard/settings/page.tsx | 2 + .../tracker-web/src/lib/product-context.tsx | 17 ++++- 6 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 dashboards/tracker-web/src/__tests__/product-context.test.tsx diff --git a/dashboards/tracker-web/e2e/tracker.spec.ts b/dashboards/tracker-web/e2e/tracker.spec.ts index 01a6b2df..aac581db 100644 --- a/dashboards/tracker-web/e2e/tracker.spec.ts +++ b/dashboards/tracker-web/e2e/tracker.spec.ts @@ -323,6 +323,12 @@ test.describe('Tracker — Authenticated dashboard', () => { await expect(page.getByRole('heading', { name: 'Product context' })).toBeVisible(); await expect(page.getByLabel('Default product ID')).toBeVisible(); await expect(page.getByText('/api/tracker/[...path]')).toBeVisible(); + await expect(page.getByText('/api/agent/v1/[...path]')).toBeVisible(); + await expect(page.getByText('/api/webhooks/[...path]')).toBeVisible(); + await page.getByLabel('Default product ID').fill('nomgap'); + await page.getByRole('button', { name: /save product context/i }).click(); + await expect(page.getByLabel('Select product')).toHaveValue('nomgap'); + await expect(page.getByText('Saved product context.')).toBeVisible(); await expect(page.getByRole('link', { name: /open public roadmap/i })).toHaveAttribute( 'href', '/roadmap' diff --git a/dashboards/tracker-web/src/__tests__/product-context.test.tsx b/dashboards/tracker-web/src/__tests__/product-context.test.tsx new file mode 100644 index 00000000..279d8bbf --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/product-context.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom + +import { describe, expect, it, beforeAll, beforeEach, vi } from 'vitest'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { ProductProvider, useProduct } from '@/lib/product-context'; + +beforeAll(() => { + (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; +}); + +beforeEach(() => { + localStorage.clear(); +}); + +function renderProductHarness() { + const container = document.createElement('div'); + document.body.appendChild(container); + let root!: Root; + + function Harness() { + const { productId, productName, setProductId } = useProduct(); + return createElement( + 'div', + null, + createElement('span', { 'data-testid': 'product-id' }, productId), + createElement('span', { 'data-testid': 'product-name' }, productName), + createElement( + 'button', + { type: 'button', onClick: () => setProductId('chronomind') }, + 'ChronoMind' + ) + ); + } + + act(() => { + root = createRoot(container); + root.render(createElement(ProductProvider, null, createElement(Harness))); + }); + + return { container, cleanup: () => act(() => root.unmount()) }; +} + +describe('ProductProvider', () => { + it('dispatches product change events when the selected product changes', () => { + const listener = vi.fn(); + window.addEventListener('tracker:product-changed', listener); + const { container, cleanup } = renderProductHarness(); + + act(() => { + container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(localStorage.getItem('tracker_selected_product')).toBe('chronomind'); + expect(container.querySelector('[data-testid="product-id"]')?.textContent).toBe('chronomind'); + expect(listener).toHaveBeenCalledTimes(1); + + cleanup(); + window.removeEventListener('tracker:product-changed', listener); + }); + + it('syncs when settings updates localStorage and emits the shared product event', () => { + const { container, cleanup } = renderProductHarness(); + + act(() => { + localStorage.setItem('tracker_selected_product', 'nomgap'); + window.dispatchEvent(new Event('tracker:product-changed')); + }); + + expect(container.querySelector('[data-testid="product-id"]')?.textContent).toBe('nomgap'); + expect(container.querySelector('[data-testid="product-name"]')?.textContent).toBe('NomGap'); + + cleanup(); + }); +}); diff --git a/dashboards/tracker-web/src/app/dashboard/board/page.tsx b/dashboards/tracker-web/src/app/dashboard/board/page.tsx index d7c589e9..26dd2629 100644 --- a/dashboards/tracker-web/src/app/dashboard/board/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/board/page.tsx @@ -16,6 +16,7 @@ import { type StatusTone, } from '@/components/ui/Primitives'; import { useAuth } from '@/lib/auth-context'; +import { useProduct } from '@/lib/product-context'; import { listItems, updateItemStatus, type TrackerItem } from '@/lib/tracker-client'; const COLUMNS: { key: string; label: string; color: string }[] = [ @@ -40,10 +41,13 @@ const PRIORITY_TONE: Record = { export default function BoardPage() { const { token } = useAuth(); + const { productId } = useProduct(); const [items, setItems] = useState([]); const fetchItems = useCallback(async () => { try { + // Re-run when the selected product changes; apiFetch reads the product header from localStorage. + void productId; const res = await listItems({ limit: '200' }); setItems(res.items); } catch (err: unknown) { @@ -53,7 +57,7 @@ export default function BoardPage() { description: err instanceof Error ? err.message : undefined, }); } - }, []); + }, [productId]); useEffect(() => { if (token) void fetchItems(); diff --git a/dashboards/tracker-web/src/app/dashboard/items/page.tsx b/dashboards/tracker-web/src/app/dashboard/items/page.tsx index 0e28fb21..44e4d598 100644 --- a/dashboards/tracker-web/src/app/dashboard/items/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/items/page.tsx @@ -19,6 +19,7 @@ import { type StatusTone, } from '@/components/ui/Primitives'; import { useAuth } from '@/lib/auth-context'; +import { useProduct } from '@/lib/product-context'; import { listItems, createItem, deleteItem, type TrackerItem } from '@/lib/tracker-client'; const TYPE_TONE: Record = { @@ -44,6 +45,7 @@ const STATUS_TONE: Record = { export default function ItemsListPage() { const { token } = useAuth(); + const { productId } = useProduct(); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -67,6 +69,8 @@ export default function ItemsListPage() { const fetchItems = useCallback(async () => { setLoading(true); try { + // Re-run when the selected product changes; apiFetch reads the product header from localStorage. + void productId; const params: Record = {}; if (typeFilter) params.type = typeFilter; if (statusFilter) params.status = statusFilter; @@ -84,7 +88,7 @@ export default function ItemsListPage() { } finally { setLoading(false); } - }, [typeFilter, statusFilter, priorityFilter, search]); + }, [typeFilter, statusFilter, priorityFilter, search, productId]); useEffect(() => { if (token) fetchItems(); diff --git a/dashboards/tracker-web/src/app/dashboard/settings/page.tsx b/dashboards/tracker-web/src/app/dashboard/settings/page.tsx index 33b714f0..eb95af66 100644 --- a/dashboards/tracker-web/src/app/dashboard/settings/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/settings/page.tsx @@ -93,6 +93,8 @@ export default function SettingsPage() { {[ ['/api/tracker/[...path]', 'Authenticated tracker and public roadmap proxy'], ['/api/fleet/[...path]', 'Agent/fleet control-plane proxy'], + ['/api/agent/v1/[...path]', 'Agent-facing item/claim/update API proxy'], + ['/api/webhooks/[...path]', 'GitHub, Gitea, GitLab, and external webhook ingestion'], ['/api/auth/*', 'Login, MFA, OAuth, and session lookup'], ['/api/telemetry/ingest', 'Client telemetry ingestion'], ['/api/health', 'Readiness and platform-service dependency probe'], diff --git a/dashboards/tracker-web/src/lib/product-context.tsx b/dashboards/tracker-web/src/lib/product-context.tsx index 8040c986..60bc3251 100644 --- a/dashboards/tracker-web/src/lib/product-context.tsx +++ b/dashboards/tracker-web/src/lib/product-context.tsx @@ -1,9 +1,10 @@ 'use client'; -import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-constants'; const STORAGE_KEY = 'tracker_selected_product'; +const PRODUCT_CHANGED_EVENT = 'tracker:product-changed'; interface ProductContextValue { productId: string; @@ -22,10 +23,24 @@ function getInitialProduct(): string { export function ProductProvider({ children }: { children: ReactNode }) { const [productId, setProductIdState] = useState(getInitialProduct); + useEffect(() => { + function syncSelectedProduct() { + setProductIdState(getInitialProduct()); + } + + window.addEventListener(PRODUCT_CHANGED_EVENT, syncSelectedProduct); + window.addEventListener('storage', syncSelectedProduct); + return () => { + window.removeEventListener(PRODUCT_CHANGED_EVENT, syncSelectedProduct); + window.removeEventListener('storage', syncSelectedProduct); + }; + }, []); + const setProductId = useCallback((id: string) => { setProductIdState(id); if (typeof window !== 'undefined') { localStorage.setItem(STORAGE_KEY, id); + window.dispatchEvent(new Event(PRODUCT_CHANGED_EVENT)); } }, []);