feat(tracker-web): sync admin product context
Some checks failed
Publish @bytelyst/* packages / publish (push) Failing after 13s
CI — Common Platform / Build, Test & Typecheck (push) Successful in 48s

This commit is contained in:
Saravana Kumar 2026-05-30 20:27:27 +00:00
parent c1a88a39e2
commit e69ffadb4b
6 changed files with 110 additions and 3 deletions

View File

@ -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'

View File

@ -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<string, unknown>).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();
});
});

View File

@ -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<string, StatusTone> = {
export default function BoardPage() {
const { token } = useAuth();
const { productId } = useProduct();
const [items, setItems] = useState<TrackerItem[]>([]);
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();

View File

@ -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<string, StatusTone> = {
@ -44,6 +45,7 @@ const STATUS_TONE: Record<string, StatusTone> = {
export default function ItemsListPage() {
const { token } = useAuth();
const { productId } = useProduct();
const [items, setItems] = useState<TrackerItem[]>([]);
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<string, string> = {};
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();

View File

@ -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'],

View File

@ -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<string>(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));
}
}, []);