diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx new file mode 100644 index 0000000..ee87809 --- /dev/null +++ b/apps/web/src/App.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from './App.js'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const { mockListProducts, mockGetProductBySlug, mockListItemsForProduct } = vi.hoisted(() => ({ + mockListProducts: vi.fn(), + mockGetProductBySlug: vi.fn(), + mockListItemsForProduct: vi.fn(), +})); + +vi.mock('./lib/api.js', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + api: { + ...real.api, + listProducts: mockListProducts, + getProductBySlug: mockGetProductBySlug, + listItemsForProduct: mockListItemsForProduct, + }, + }; +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function ok(data: T) { + const safeData = + data && typeof data === 'object' + ? Array.isArray(data) + ? ([...data] as T) + : ({ ...data } as T) + : data; + return { ok: true as const, data: safeData }; +} + +const PRODUCTS = [ + { product: { slug: 'helm', name: 'Helm' }, workflow: { stages_enabled: ['discovery'] as const } }, +]; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('App routing', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('shows empty state when registry returns no products', async () => { + mockListProducts.mockResolvedValue(ok([])); + render(); + + await vi.waitFor(() => { + expect(screen.getByText('No products registered.')).toBeInTheDocument(); + }); + }); + + it('redirects / to /products/:firstSlug when products exist', async () => { + mockListProducts.mockResolvedValue(ok(PRODUCTS)); + mockGetProductBySlug.mockResolvedValue(ok(PRODUCTS[0])); + mockListItemsForProduct.mockResolvedValue(ok([])); + render(); + + await vi.waitFor(() => { + expect(window.location.pathname).toBe('/products/helm'); + expect(screen.getByRole('heading', { name: /Helm/ })).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b249c67..453f782 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,13 +1,92 @@ -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { useCallback } from 'react'; +import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'; import { Kanban } from './views/Kanban.js'; import { ItemDetail } from './views/ItemDetail.js'; +import { api } from './lib/api.js'; +import { usePolling } from './hooks/usePolling.js'; + +// ── Redirect helpers ────────────────────────────────────────────────────────── + +/** + * Resolves the first registered product slug and redirects to /products/:slug. + * Shows an empty state if the registry is empty so the app never crashes. + */ +function ProductsRedirect() { + const fetchProducts = useCallback(() => api.listProducts(), []); + const { data: products, error, loading } = usePolling(fetchProducts, null); + + if (loading) { + return ( +
+

Loading…

+
+ ); + } + + if (error) { + return ( +
+

Failed to load products: {error}

+
+ ); + } + + if (!products?.length) { + return ( +
+
+

No products registered.

+

+ Add a product to .helm/products.yaml and restart the server. +

+
+
+ ); + } + + return ; +} + +/** + * Legacy /items/:id → /products/:firstSlug/items/:id so old bookmarks still work. + */ +function LegacyItemRedirect() { + const { id } = useParams<{ id: string }>(); + const fetchProducts = useCallback(() => api.listProducts(), []); + const { data: products, error, loading } = usePolling(fetchProducts, null); + + if (loading) { + return ( +
+

Loading…

+
+ ); + } + + if (error) return ; + if (!products?.length || !id) return ; + return ( + + ); +} + +// ── App ─────────────────────────────────────────────────────────────────────── export default function App() { return ( - } /> - } /> + {/* Multi-product routes */} + } /> + } /> + } /> + + {/* Backward-compat redirects */} + } /> + } /> ); diff --git a/apps/web/src/components/ProductTabs.test.tsx b/apps/web/src/components/ProductTabs.test.tsx new file mode 100644 index 0000000..b0f046c --- /dev/null +++ b/apps/web/src/components/ProductTabs.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProductTabs } from './ProductTabs.js'; + +// ── Mock api ────────────────────────────────────────────────────────────────── + +const { mockListProducts } = vi.hoisted(() => ({ mockListProducts: vi.fn() })); + +vi.mock('../lib/api.js', async (importOriginal) => { + const real = await importOriginal(); + return { ...real, api: { ...real.api, listProducts: mockListProducts } }; +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const PRODUCTS = [ + { product: { slug: 'helm', name: 'Helm' }, workflow: { stages_enabled: [] } }, + { + product: { slug: 'helm-playground', name: 'Helm Playground' }, + workflow: { stages_enabled: [] }, + }, +]; + +function ok(data: T) { + return { ok: true as const, data }; +} +function err(message: string) { + return { ok: false as const, error: { type: 'network' as const, message } }; +} + +function renderTabs(initialPath = '/products/helm') { + return render( + + + } /> + } /> + + , + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ProductTabs', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders a tab for each product', async () => { + mockListProducts.mockResolvedValue(ok(PRODUCTS)); + renderTabs(); + + await vi.waitFor(() => { + expect(screen.getByText('Helm')).toBeInTheDocument(); + expect(screen.getByText('Helm Playground')).toBeInTheDocument(); + }); + }); + + it('marks the active tab based on the current slug', async () => { + mockListProducts.mockResolvedValue(ok(PRODUCTS)); + renderTabs('/products/helm-playground'); + + await vi.waitFor(() => { + const activeLink = screen.getByRole('link', { name: 'Helm Playground' }); + expect(activeLink).toHaveAttribute('aria-current', 'page'); + const inactiveLink = screen.getByRole('link', { name: 'Helm' }); + expect(inactiveLink).not.toHaveAttribute('aria-current'); + }); + }); + + it('each tab links to /products/:slug', async () => { + mockListProducts.mockResolvedValue(ok(PRODUCTS)); + renderTabs(); + + await vi.waitFor(() => { + expect(screen.getByRole('link', { name: 'Helm' })).toHaveAttribute('href', '/products/helm'); + expect(screen.getByRole('link', { name: 'Helm Playground' })).toHaveAttribute( + 'href', + '/products/helm-playground', + ); + }); + }); + + it('shows error state when listProducts fails', async () => { + mockListProducts.mockResolvedValue(err('Network error')); + renderTabs(); + + await vi.waitFor(() => { + expect(screen.getByText(/Failed to load products/)).toBeInTheDocument(); + }); + }); + + it('renders nothing when product list is empty', async () => { + mockListProducts.mockResolvedValue(ok([])); + const { container } = renderTabs(); + + await vi.waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); + + it('re-fetches products after polling interval', async () => { + mockListProducts.mockResolvedValue(ok(PRODUCTS)); + renderTabs(); + + await vi.waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1)); + + await vi.runOnlyPendingTimersAsync(); + await vi.waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/apps/web/src/components/ProductTabs.tsx b/apps/web/src/components/ProductTabs.tsx new file mode 100644 index 0000000..1ca5f1a --- /dev/null +++ b/apps/web/src/components/ProductTabs.tsx @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import { usePolling } from '../hooks/usePolling.js'; + +/** + * Horizontal product tabs rendered above every kanban/detail view. + * Polls every 5s so newly-registered products appear without a page reload. + */ +export function ProductTabs() { + const { slug } = useParams<{ slug?: string }>(); + const fetchProducts = useCallback(() => api.listProducts(), []); + const { data: products, error, loading } = usePolling(fetchProducts, 5_000); + + // Blank bar while loading — avoids layout shift. + if (loading) { + return
; + } + + if (error) { + return ( +
+

⚠ Failed to load products

+
+ ); + } + + if (!products?.length) return null; + + return ( + + ); +} diff --git a/apps/web/src/lib/api.test.ts b/apps/web/src/lib/api.test.ts index c509e2f..d58803f 100644 --- a/apps/web/src/lib/api.test.ts +++ b/apps/web/src/lib/api.test.ts @@ -78,3 +78,60 @@ describe('api.getItem', () => { expect(result.ok).toBe(false); }); }); + +describe('api.listProducts', () => { + it('returns array of products', async () => { + const products = [ + { product: { slug: 'helm', name: 'Helm' } }, + { product: { slug: 'helm-playground', name: 'Helm Playground' } }, + ]; + mockFetch.mockResolvedValueOnce(mockOk(products)); + const result = await api.listProducts(); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toHaveLength(2); + }); + + it('returns network error on fetch throw', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + const result = await api.listProducts(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.type).toBe('network'); + }); +}); + +describe('api.getProductBySlug', () => { + it('URL-encodes special characters in the slug', async () => { + const slug = 'helm/playground with space'; + mockFetch.mockResolvedValueOnce(mockOk({ product: { slug } })); + await api.getProductBySlug(slug); + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain(`/api/products/${encodeURIComponent(slug)}`); + }); + + it('returns http error on 404', async () => { + mockFetch.mockResolvedValueOnce(mockErr(404, { error: 'Product not found: ghost' })); + const result = await api.getProductBySlug('ghost'); + expect(result.ok).toBe(false); + if (!result.ok) expect((result.error as { status: number }).status).toBe(404); + }); +}); + +describe('api.listItemsForProduct', () => { + it('fetches from the product-scoped items endpoint and URL-encodes the slug', async () => { + const slug = 'helm/playground with space'; + const items = [{ externalId: 'issue_1', productSlug: slug }]; + mockFetch.mockResolvedValueOnce(mockOk(items)); + const result = await api.listItemsForProduct(slug); + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain(`/api/products/${encodeURIComponent(slug)}/items`); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toHaveLength(1); + }); + + it('returns empty array when product has no items', async () => { + mockFetch.mockResolvedValueOnce(mockOk([])); + const result = await api.listItemsForProduct('new-product'); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toHaveLength(0); + }); +}); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9eeddf2..8bd0292 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -68,6 +68,16 @@ async function fetchJson(path: string): Promise> { // ── API client ──────────────────────────────────────────────────────────────── export const api = { + // ── Multi-product routes (Session 8+) ────────────────────────────────────── + listProducts: (): Promise> => fetchJson('/api/products'), + + getProductBySlug: (slug: string): Promise> => + fetchJson(`/api/products/${encodeURIComponent(slug)}`), + + listItemsForProduct: (slug: string): Promise> => + fetchJson(`/api/products/${encodeURIComponent(slug)}/items`), + + // ── Legacy single-product routes — kept for backward compat, remove after cleanup PR ── getProduct: (): Promise> => fetchJson('/api/product'), listItems: (): Promise> => fetchJson('/api/items'), getItem: (id: string): Promise> => diff --git a/apps/web/src/views/ItemDetail.tsx b/apps/web/src/views/ItemDetail.tsx index 50cef69..6ba96ad 100644 --- a/apps/web/src/views/ItemDetail.tsx +++ b/apps/web/src/views/ItemDetail.tsx @@ -41,18 +41,23 @@ function HistoryRow({ event, index }: { event: WorkflowEvent; index: number }) { } export function ItemDetail() { - const { id } = useParams<{ id: string }>(); + const { slug, externalId } = useParams<{ slug: string; externalId: string }>(); const fetcher = useCallback((): ReturnType => { - if (!id) { + if (!externalId) { return Promise.resolve({ ok: false, error: { type: 'network', message: 'Missing item id in route.' }, }); } - return api.getItem(id); - }, [id]); - const { data: item, error, loading } = usePolling(fetcher, id ? 5_000 : null); + return api.getItem(externalId); + }, [externalId]); + + const { data: item, error, loading } = usePolling(fetcher, externalId ? 5_000 : null); + + const backLink = slug ? `/products/${encodeURIComponent(slug)}` : '/products'; + // Guard against navigating to /products/A/items/X where X belongs to product B. + const slugMismatch = Boolean(item && slug && item.productSlug !== slug); if (loading) { return ( @@ -64,12 +69,14 @@ export function ItemDetail() { // Full-page error only when there's no prior data — transient poll failures // show an inline warning so the last-known content stays visible. - if (error && !item) { + if ((error && !item) || slugMismatch) { return (
-

{error}

- +

+ {slugMismatch ? 'Item does not belong to this product.' : error} +

+ ← Back to board
@@ -84,7 +91,7 @@ export function ItemDetail() { {/* Header */}
← Back to board diff --git a/apps/web/src/views/Kanban.test.tsx b/apps/web/src/views/Kanban.test.tsx new file mode 100644 index 0000000..e24d738 --- /dev/null +++ b/apps/web/src/views/Kanban.test.tsx @@ -0,0 +1,133 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Kanban } from './Kanban.js'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const { mockGetProductBySlug, mockListItemsForProduct, mockListProducts } = vi.hoisted(() => ({ + mockGetProductBySlug: vi.fn(), + mockListItemsForProduct: vi.fn(), + mockListProducts: vi.fn(), +})); + +vi.mock('../lib/api.js', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + api: { + ...real.api, + getProductBySlug: mockGetProductBySlug, + listItemsForProduct: mockListItemsForProduct, + listProducts: mockListProducts, + }, + }; +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function ok(data: T) { + return { ok: true as const, data }; +} +function err(message: string) { + return { ok: false as const, error: { type: 'network' as const, message } }; +} + +const PRODUCT = { + product: { slug: 'helm-playground', name: 'Helm Playground' }, + workflow: { stages_enabled: ['discovery', 'spec-ready'] as const }, +}; + +const ITEMS = [ + { + externalId: 'issue_1', + productSlug: 'helm-playground', + currentStage: 'discovery', + history: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, +]; + +function renderKanban(slug = 'helm-playground') { + return render( + + + } /> + + , + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('Kanban', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockListProducts.mockResolvedValue(ok([])); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders columns from product stages_enabled', async () => { + mockGetProductBySlug.mockResolvedValue(ok(PRODUCT)); + mockListItemsForProduct.mockResolvedValue(ok(ITEMS)); + renderKanban(); + + // Stage names appear lowercase in DOM; CSS `uppercase` is a visual-only transform. + // getAllByText is used because stage name also appears in the item card subtitle. + await vi.waitFor(() => { + expect(screen.getAllByText('discovery').length).toBeGreaterThan(0); + expect(screen.getAllByText('spec-ready').length).toBeGreaterThan(0); + }); + }); + + it('shows item card in the correct column', async () => { + mockGetProductBySlug.mockResolvedValue(ok(PRODUCT)); + mockListItemsForProduct.mockResolvedValue(ok(ITEMS)); + renderKanban(); + + await vi.waitFor(() => { + expect(screen.getByText('issue_1')).toBeInTheDocument(); + }); + }); + + it('item card links to /products/:slug/items/:externalId', async () => { + mockGetProductBySlug.mockResolvedValue(ok(PRODUCT)); + mockListItemsForProduct.mockResolvedValue(ok(ITEMS)); + renderKanban(); + + await vi.waitFor(() => { + const link = screen.getByRole('link', { name: /issue_1/ }); + expect(link).toHaveAttribute('href', '/products/helm-playground/items/issue_1'); + }); + }); + + it('shows error with back link when product fetch fails', async () => { + mockGetProductBySlug.mockResolvedValue(err('Product not found: ghost')); + mockListItemsForProduct.mockResolvedValue(ok([])); + renderKanban('ghost'); + + await vi.waitFor(() => { + expect(screen.getByText('Product not found: ghost')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /back to products/i })).toHaveAttribute( + 'href', + '/products', + ); + }); + }); + + it('renders empty columns (no crash) when items list is empty', async () => { + mockGetProductBySlug.mockResolvedValue(ok(PRODUCT)); + mockListItemsForProduct.mockResolvedValue(ok([])); + renderKanban(); + + await vi.waitFor(() => { + expect(screen.getByText('discovery')).toBeInTheDocument(); + expect(screen.getAllByText('—').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/web/src/views/Kanban.tsx b/apps/web/src/views/Kanban.tsx index bfba294..a5b4a33 100644 --- a/apps/web/src/views/Kanban.tsx +++ b/apps/web/src/views/Kanban.tsx @@ -1,9 +1,10 @@ import { useCallback, useMemo } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { api } from '../lib/api.js'; import type { ItemState } from '../lib/api.js'; import type { WorkflowStage } from '@helm/workflow'; import { usePolling } from '../hooks/usePolling.js'; +import { ProductTabs } from '../components/ProductTabs.js'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -22,10 +23,10 @@ function relativeTime(iso: string): string { // ── Sub-components ──────────────────────────────────────────────────────────── -function ItemCard({ item }: { item: ItemState }) { +function ItemCard({ item, slug }: { item: ItemState; slug: string }) { return (

{item.externalId}

@@ -35,7 +36,15 @@ function ItemCard({ item }: { item: ItemState }) { ); } -function KanbanColumn({ stage, items }: { stage: WorkflowStage; items: ItemState[] }) { +function KanbanColumn({ + stage, + items, + slug, +}: { + stage: WorkflowStage; + items: ItemState[]; + slug: string; +}) { return (
@@ -46,7 +55,7 @@ function KanbanColumn({ stage, items }: { stage: WorkflowStage; items: ItemState
{items.map((item) => ( - + ))} {items.length === 0 &&

}
@@ -57,12 +66,42 @@ function KanbanColumn({ stage, items }: { stage: WorkflowStage; items: ItemState // ── Main view ───────────────────────────────────────────────────────────────── export function Kanban() { - const fetchProduct = useCallback(() => api.getProduct(), []); - const fetchItems = useCallback(() => api.listItems(), []); + const { slug } = useParams<{ slug: string }>(); + + const fetchProduct = useCallback( + () => + slug + ? api.getProductBySlug(slug) + : Promise.resolve({ + ok: false as const, + error: { type: 'network' as const, message: 'Missing product slug in route.' }, + }), + [slug], + ); + const fetchItems = useCallback( + () => + slug + ? api.listItemsForProduct(slug) + : Promise.resolve({ ok: true as const, data: [] as ItemState[] }), + [slug], + ); const { data: product, error: productError, loading } = usePolling(fetchProduct, null); const { data: items, error: itemsError } = usePolling(fetchItems, 5_000); + // useMemo must be called unconditionally — before any early returns. + const stages = product?.workflow.stages_enabled ?? []; + const allItems = items ?? []; + const itemsByStage = useMemo(() => { + const grouped = new Map(); + for (const item of allItems) { + const list = grouped.get(item.currentStage) ?? []; + list.push(item); + grouped.set(item.currentStage, list); + } + return grouped; + }, [allItems]); + if (loading) { return (
@@ -73,25 +112,23 @@ export function Kanban() { if (productError) { return ( -
-

{productError}

+
+ +
+
+

{productError}

+ + ← Back to products + +
+
); } - const stages = product?.workflow.stages_enabled ?? []; - const allItems = items ?? []; - - const itemsByStage = useMemo(() => { - const grouped = new Map(); - for (const item of allItems) { - const list = grouped.get(item.currentStage) ?? []; - list.push(item); - grouped.set(item.currentStage, list); - } - return grouped; - }, [allItems]); - return (
{/* Header */} @@ -109,10 +146,18 @@ export function Kanban() {
+ {/* Product tabs */} + + {/* Board */}
{stages.map((stage) => ( - + ))}