Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions apps/web/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('./lib/api.js')>();
return {
...real,
api: {
...real.api,
listProducts: mockListProducts,
getProductBySlug: mockGetProductBySlug,
listItemsForProduct: mockListItemsForProduct,
},
};
});

// ── Helpers ───────────────────────────────────────────────────────────────────

function ok<T>(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 };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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(<App />);

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(<App />);

await vi.waitFor(() => {
expect(window.location.pathname).toBe('/products/helm');
expect(screen.getByRole('heading', { name: /Helm/ })).toBeInTheDocument();
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
85 changes: 82 additions & 3 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<p className="text-sm text-gray-400">Loading…</p>
</div>
);
}

if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<p className="text-sm text-red-500">Failed to load products: {error}</p>
</div>
);
}

if (!products?.length) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<p className="text-sm text-gray-500">No products registered.</p>
<p className="mt-1 text-xs text-gray-400">
Add a product to <code>.helm/products.yaml</code> and restart the server.
</p>
</div>
</div>
);
}

return <Navigate to={`/products/${encodeURIComponent(products[0]!.product.slug)}`} replace />;
}

/**
* 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 (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<p className="text-sm text-gray-400">Loading…</p>
</div>
);
}

if (error) return <Navigate to="/" replace />;
if (!products?.length || !id) return <Navigate to="/" replace />;
return (
<Navigate
to={`/products/${encodeURIComponent(products[0]!.product.slug)}/items/${encodeURIComponent(id)}`}
replace
/>
);
}

// ── App ───────────────────────────────────────────────────────────────────────

export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Kanban />} />
<Route path="/items/:id" element={<ItemDetail />} />
{/* Multi-product routes */}
<Route path="/products" element={<ProductsRedirect />} />
<Route path="/products/:slug" element={<Kanban />} />
<Route path="/products/:slug/items/:externalId" element={<ItemDetail />} />

{/* Backward-compat redirects */}
<Route path="/" element={<ProductsRedirect />} />
<Route path="/items/:id" element={<LegacyItemRedirect />} />
</Routes>
</BrowserRouter>
);
Expand Down
117 changes: 117 additions & 0 deletions apps/web/src/components/ProductTabs.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('../lib/api.js')>();
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<T>(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(
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/products/:slug" element={<ProductTabs />} />
<Route path="/products/:slug/items/:externalId" element={<ProductTabs />} />
</Routes>
</MemoryRouter>,
);
}

// ── 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));
});
});
54 changes: 54 additions & 0 deletions apps/web/src/components/ProductTabs.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="h-10 border-b border-gray-200 bg-white" />;
}

if (error) {
return (
<div className="border-b border-gray-200 bg-white px-6 py-2">
<p className="text-xs text-red-500">⚠ Failed to load products</p>
</div>
);
}

if (!products?.length) return null;

return (
<nav aria-label="Products" className="border-b border-gray-200 bg-white px-6">
<div className="flex">
{products.map((product) => {
const isActive = product.product.slug === slug;
return (
<Link
key={product.product.slug}
to={`/products/${encodeURIComponent(product.product.slug)}`}
aria-current={isActive ? 'page' : undefined}
className={[
'px-4 py-3 text-sm font-medium transition-colors',
isActive
? 'border-b-2 border-indigo-600 text-indigo-600'
: 'border-b-2 border-transparent text-gray-500 hover:text-gray-700',
].join(' ')}
>
{product.product.name}
</Link>
);
})}
</div>
</nav>
);
}
Loading