diff --git a/dashboards/tracker-web/.env.example b/dashboards/tracker-web/.env.example index 672076dd..6cbed1a9 100644 --- a/dashboards/tracker-web/.env.example +++ b/dashboards/tracker-web/.env.example @@ -7,6 +7,12 @@ PRODUCT_ID=lysnrai NEXT_PUBLIC_PRODUCT_ID=lysnrai +# Optional: override the selectable product/project list shown in the sidebar +# switcher. JSON array of { id, name, icon? }. Leave unset to use the built-in +# default. With one entry the switcher auto-hides (single-tenant deployments). +# Example: NEXT_PUBLIC_PRODUCTS=[{"id":"acme","name":"Acme App"}] +NEXT_PUBLIC_PRODUCTS= + # ── Microservice URLs (consolidated platform-service) ── PLATFORM_SERVICE_URL=http://localhost:4003 PLATFORM_API_URL=http://localhost:4003 diff --git a/dashboards/tracker-web/src/__tests__/product-constants.test.ts b/dashboards/tracker-web/src/__tests__/product-constants.test.ts new file mode 100644 index 00000000..7d9bafe9 --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/product-constants.test.ts @@ -0,0 +1,51 @@ +/** + * Configurable product list (generic-platform support): the NEXT_PUBLIC_PRODUCTS + * override parser must be defensive so a bad env value can never blank the switcher. + */ + +import { describe, it, expect } from 'vitest'; +import { parseProductsEnv, DEFAULT_PRODUCTS, type Product } from '@/lib/product-constants'; + +const fallback: readonly Product[] = [{ id: 'only', name: 'Only' }]; + +describe('parseProductsEnv', () => { + it('returns the fallback for empty / missing input', () => { + expect(parseProductsEnv(undefined, fallback)).toBe(fallback); + expect(parseProductsEnv('', fallback)).toBe(fallback); + expect(parseProductsEnv(' ', fallback)).toBe(fallback); + }); + + it('parses a valid JSON array of products', () => { + const raw = JSON.stringify([ + { id: 'acme', name: 'Acme App', icon: 'Box' }, + { id: 'beta', name: 'Beta' }, + ]); + expect(parseProductsEnv(raw, fallback)).toEqual([ + { id: 'acme', name: 'Acme App', icon: 'Box' }, + { id: 'beta', name: 'Beta' }, + ]); + }); + + it('defaults a missing name to the id and drops the icon when absent', () => { + expect(parseProductsEnv(JSON.stringify([{ id: 'x' }]), fallback)).toEqual([ + { id: 'x', name: 'x' }, + ]); + }); + + it('skips malformed entries but keeps valid ones', () => { + const raw = JSON.stringify([{ id: '' }, null, 42, { name: 'no id' }, { id: 'ok', name: 'OK' }]); + expect(parseProductsEnv(raw, fallback)).toEqual([{ id: 'ok', name: 'OK' }]); + }); + + it('falls back on non-array JSON, invalid JSON, or an all-invalid array', () => { + expect(parseProductsEnv('{"id":"x"}', fallback)).toBe(fallback); // object, not array + expect(parseProductsEnv('not json', fallback)).toBe(fallback); + expect(parseProductsEnv(JSON.stringify([{ bad: true }]), fallback)).toBe(fallback); + }); + + it('the built-in default is non-empty with unique ids', () => { + expect(DEFAULT_PRODUCTS.length).toBeGreaterThan(0); + const ids = DEFAULT_PRODUCTS.map(p => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/dashboards/tracker-web/src/__tests__/product-switcher.test.tsx b/dashboards/tracker-web/src/__tests__/product-switcher.test.tsx new file mode 100644 index 00000000..2276deaa --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/product-switcher.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +/** + * ProductSwitcher — auto-hides for single-tenant deployments and lists the + * configured products otherwise. + */ + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { Product } from '@/lib/product-constants'; + +const useProduct = vi.fn(); +vi.mock('@/lib/product-context', () => ({ useProduct: () => useProduct() })); + +import { ProductSwitcher } from '@/components/product-switcher'; + +beforeAll(() => { + (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; +}); + +function render(products: readonly Product[]): { container: HTMLDivElement; root: Root } { + useProduct.mockReturnValue({ + productId: products[0]?.id ?? 'x', + setProductId: vi.fn(), + products, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(createElement(ProductSwitcher)); + }); + return { container, root }; +} + +describe('ProductSwitcher', () => { + it('renders nothing when there is zero or one product', () => { + const none = render([]); + expect(none.container.querySelector('select')).toBeNull(); + act(() => none.root.unmount()); + + const one = render([{ id: 'solo', name: 'Solo' }]); + expect(one.container.querySelector('select')).toBeNull(); + act(() => one.root.unmount()); + }); + + it('renders a select with an option per product when there are multiple', () => { + const { container, root } = render([ + { id: 'a', name: 'Alpha' }, + { id: 'b', name: 'Beta' }, + ]); + const select = container.querySelector('select'); + expect(select).not.toBeNull(); + expect(select!.querySelectorAll('option')).toHaveLength(2); + expect(container.textContent).toContain('Alpha'); + expect(container.textContent).toContain('Beta'); + act(() => root.unmount()); + }); +}); diff --git a/dashboards/tracker-web/src/components/product-switcher.tsx b/dashboards/tracker-web/src/components/product-switcher.tsx index 1873ca5b..6a6dd195 100644 --- a/dashboards/tracker-web/src/components/product-switcher.tsx +++ b/dashboards/tracker-web/src/components/product-switcher.tsx @@ -5,6 +5,10 @@ import { useProduct } from '@/lib/product-context'; export function ProductSwitcher() { const { productId, setProductId, products } = useProduct(); + // Single-tenant deployments (a solo dev / freelancer with one project) get no + // switcher clutter — there is nothing to switch between. + if (products.length <= 1) return null; + return (