feat(tracker-web): sync admin product context
This commit is contained in:
parent
c1a88a39e2
commit
e69ffadb4b
@ -323,6 +323,12 @@ test.describe('Tracker — Authenticated dashboard', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Product context' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Product context' })).toBeVisible();
|
||||||
await expect(page.getByLabel('Default product ID')).toBeVisible();
|
await expect(page.getByLabel('Default product ID')).toBeVisible();
|
||||||
await expect(page.getByText('/api/tracker/[...path]')).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(
|
await expect(page.getByRole('link', { name: /open public roadmap/i })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'/roadmap'
|
'/roadmap'
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
type StatusTone,
|
type StatusTone,
|
||||||
} from '@/components/ui/Primitives';
|
} from '@/components/ui/Primitives';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
|
import { useProduct } from '@/lib/product-context';
|
||||||
import { listItems, updateItemStatus, type TrackerItem } from '@/lib/tracker-client';
|
import { listItems, updateItemStatus, type TrackerItem } from '@/lib/tracker-client';
|
||||||
|
|
||||||
const COLUMNS: { key: string; label: string; color: string }[] = [
|
const COLUMNS: { key: string; label: string; color: string }[] = [
|
||||||
@ -40,10 +41,13 @@ const PRIORITY_TONE: Record<string, StatusTone> = {
|
|||||||
|
|
||||||
export default function BoardPage() {
|
export default function BoardPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const { productId } = useProduct();
|
||||||
const [items, setItems] = useState<TrackerItem[]>([]);
|
const [items, setItems] = useState<TrackerItem[]>([]);
|
||||||
|
|
||||||
const fetchItems = useCallback(async () => {
|
const fetchItems = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Re-run when the selected product changes; apiFetch reads the product header from localStorage.
|
||||||
|
void productId;
|
||||||
const res = await listItems({ limit: '200' });
|
const res = await listItems({ limit: '200' });
|
||||||
setItems(res.items);
|
setItems(res.items);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -53,7 +57,7 @@ export default function BoardPage() {
|
|||||||
description: err instanceof Error ? err.message : undefined,
|
description: err instanceof Error ? err.message : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, [productId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) void fetchItems();
|
if (token) void fetchItems();
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
type StatusTone,
|
type StatusTone,
|
||||||
} from '@/components/ui/Primitives';
|
} from '@/components/ui/Primitives';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
|
import { useProduct } from '@/lib/product-context';
|
||||||
import { listItems, createItem, deleteItem, type TrackerItem } from '@/lib/tracker-client';
|
import { listItems, createItem, deleteItem, type TrackerItem } from '@/lib/tracker-client';
|
||||||
|
|
||||||
const TYPE_TONE: Record<string, StatusTone> = {
|
const TYPE_TONE: Record<string, StatusTone> = {
|
||||||
@ -44,6 +45,7 @@ const STATUS_TONE: Record<string, StatusTone> = {
|
|||||||
|
|
||||||
export default function ItemsListPage() {
|
export default function ItemsListPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const { productId } = useProduct();
|
||||||
const [items, setItems] = useState<TrackerItem[]>([]);
|
const [items, setItems] = useState<TrackerItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -67,6 +69,8 @@ export default function ItemsListPage() {
|
|||||||
const fetchItems = useCallback(async () => {
|
const fetchItems = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// Re-run when the selected product changes; apiFetch reads the product header from localStorage.
|
||||||
|
void productId;
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (typeFilter) params.type = typeFilter;
|
if (typeFilter) params.type = typeFilter;
|
||||||
if (statusFilter) params.status = statusFilter;
|
if (statusFilter) params.status = statusFilter;
|
||||||
@ -84,7 +88,7 @@ export default function ItemsListPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [typeFilter, statusFilter, priorityFilter, search]);
|
}, [typeFilter, statusFilter, priorityFilter, search, productId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) fetchItems();
|
if (token) fetchItems();
|
||||||
|
|||||||
@ -93,6 +93,8 @@ export default function SettingsPage() {
|
|||||||
{[
|
{[
|
||||||
['/api/tracker/[...path]', 'Authenticated tracker and public roadmap proxy'],
|
['/api/tracker/[...path]', 'Authenticated tracker and public roadmap proxy'],
|
||||||
['/api/fleet/[...path]', 'Agent/fleet control-plane 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/auth/*', 'Login, MFA, OAuth, and session lookup'],
|
||||||
['/api/telemetry/ingest', 'Client telemetry ingestion'],
|
['/api/telemetry/ingest', 'Client telemetry ingestion'],
|
||||||
['/api/health', 'Readiness and platform-service dependency probe'],
|
['/api/health', 'Readiness and platform-service dependency probe'],
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'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';
|
import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-constants';
|
||||||
|
|
||||||
const STORAGE_KEY = 'tracker_selected_product';
|
const STORAGE_KEY = 'tracker_selected_product';
|
||||||
|
const PRODUCT_CHANGED_EVENT = 'tracker:product-changed';
|
||||||
|
|
||||||
interface ProductContextValue {
|
interface ProductContextValue {
|
||||||
productId: string;
|
productId: string;
|
||||||
@ -22,10 +23,24 @@ function getInitialProduct(): string {
|
|||||||
export function ProductProvider({ children }: { children: ReactNode }) {
|
export function ProductProvider({ children }: { children: ReactNode }) {
|
||||||
const [productId, setProductIdState] = useState<string>(getInitialProduct);
|
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) => {
|
const setProductId = useCallback((id: string) => {
|
||||||
setProductIdState(id);
|
setProductIdState(id);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem(STORAGE_KEY, id);
|
localStorage.setItem(STORAGE_KEY, id);
|
||||||
|
window.dispatchEvent(new Event(PRODUCT_CHANGED_EVENT));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user