feat(tracker-web): make product switcher generic — configurable list + auto-hide
Steps toward a tenant-neutral platform (not hardcoded to the ByteLyst products):
- The selectable product list is now configurable via NEXT_PUBLIC_PRODUCTS
(JSON array of { id, name, icon? }), defaulting to the built-in set. A pure,
defensive parser (parseProductsEnv) falls back to the default on any malformed
value so a bad env can never blank the switcher.
- The sidebar switcher auto-hides when there is <= 1 product, so a solo / freelance
/ single-tenant deployment shows no switcher clutter.
- Dedupe: the server product-config now re-exports the single client-safe list
instead of keeping a second hardcoded copy.
NOTE: true per-user "only your projects" scoping + server-side tenant
authorization still requires an ownership/membership model that does not exist
yet (ProductDoc has no owner/members; products are a global registry). That is a
deliberate, separate effort needing a product decision and is not included here.
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
705d8e8eaa
commit
2c4357b71b
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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<string, unknown>).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());
|
||||
});
|
||||
});
|
||||
@ -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 (
|
||||
<select
|
||||
value={productId}
|
||||
|
||||
@ -10,6 +10,14 @@ import type { NextRequest } from 'next/server';
|
||||
|
||||
const identity = loadProductIdentity();
|
||||
|
||||
// Single source of truth for the configurable product list (client-safe module).
|
||||
export {
|
||||
KNOWN_PRODUCTS,
|
||||
DEFAULT_PRODUCTS,
|
||||
parseProductsEnv,
|
||||
type Product,
|
||||
} from './product-constants';
|
||||
|
||||
export const PRODUCT_ID = identity.productId;
|
||||
export const DISPLAY_NAME = identity.displayName;
|
||||
export const LICENSE_PREFIX = identity.licensePrefix;
|
||||
@ -22,11 +30,3 @@ export const PACKAGE_NAME = identity.packageName;
|
||||
export function getRequestProductId(req: NextRequest): string {
|
||||
return req.headers.get('x-product-id') || PRODUCT_ID;
|
||||
}
|
||||
|
||||
/** All known products in the ByteLyst ecosystem. */
|
||||
export const KNOWN_PRODUCTS = [
|
||||
{ id: 'lysnrai', name: 'LysnrAI', icon: 'Mic' },
|
||||
{ id: 'chronomind', name: 'ChronoMind', icon: 'Clock' },
|
||||
{ id: 'nomgap', name: 'NomGap', icon: 'Apple' },
|
||||
{ id: 'mindlyst', name: 'MindLyst', icon: 'Brain' },
|
||||
] as const;
|
||||
|
||||
@ -3,15 +3,62 @@
|
||||
*
|
||||
* Use this in 'use client' components. For server-side code that needs
|
||||
* loadProductIdentity(), import from product-config.ts instead.
|
||||
*
|
||||
* The product list is CONFIGURABLE so this dashboard works for any deployment
|
||||
* (a third-party / freelance / self-hosted operator), not just the built-in set.
|
||||
* Set `NEXT_PUBLIC_PRODUCTS` to a JSON array of `{ id, name, icon? }` to override
|
||||
* the default list; an empty/invalid value falls back to the built-in default.
|
||||
*/
|
||||
|
||||
export const PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || process.env.PRODUCT_ID || 'lysnrai';
|
||||
export const DISPLAY_NAME = process.env.NEXT_PUBLIC_DISPLAY_NAME || 'LysnrAI';
|
||||
|
||||
/** All known products in the ByteLyst ecosystem. */
|
||||
export const KNOWN_PRODUCTS = [
|
||||
/** A selectable product/project the dashboard can be scoped to. */
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** Built-in default product set (the original ByteLyst ecosystem). */
|
||||
export const DEFAULT_PRODUCTS: readonly Product[] = [
|
||||
{ id: 'lysnrai', name: 'LysnrAI', icon: 'Mic' },
|
||||
{ id: 'chronomind', name: 'ChronoMind', icon: 'Clock' },
|
||||
{ id: 'nomgap', name: 'NomGap', icon: 'Apple' },
|
||||
{ id: 'mindlyst', name: 'MindLyst', icon: 'Brain' },
|
||||
] as const;
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse the `NEXT_PUBLIC_PRODUCTS` override into a validated product list. PURE +
|
||||
* defensive: any malformed entry (missing id/name, non-array, bad JSON) falls
|
||||
* back to `fallback`, so a bad env value can never blank out the switcher.
|
||||
*/
|
||||
export function parseProductsEnv(
|
||||
raw: string | undefined,
|
||||
fallback: readonly Product[] = DEFAULT_PRODUCTS
|
||||
): readonly Product[] {
|
||||
if (!raw || raw.trim() === '') return fallback;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return fallback;
|
||||
const products: Product[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const { id, name, icon } = entry as Record<string, unknown>;
|
||||
if (typeof id !== 'string' || id.trim() === '') continue;
|
||||
products.push({
|
||||
id,
|
||||
name: typeof name === 'string' && name.trim() !== '' ? name : id,
|
||||
...(typeof icon === 'string' ? { icon } : {}),
|
||||
});
|
||||
}
|
||||
return products.length > 0 ? products : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Configured product set — `NEXT_PUBLIC_PRODUCTS` override, else the default. */
|
||||
export const KNOWN_PRODUCTS: readonly Product[] = parseProductsEnv(
|
||||
process.env.NEXT_PUBLIC_PRODUCTS
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-constants';
|
||||
import { KNOWN_PRODUCTS, PRODUCT_ID, type Product } from '@/lib/product-constants';
|
||||
|
||||
const STORAGE_KEY = 'tracker_selected_product';
|
||||
const PRODUCT_CHANGED_EVENT = 'tracker:product-changed';
|
||||
@ -10,7 +10,7 @@ interface ProductContextValue {
|
||||
productId: string;
|
||||
productName: string;
|
||||
setProductId: (id: string) => void;
|
||||
products: typeof KNOWN_PRODUCTS;
|
||||
products: readonly Product[];
|
||||
}
|
||||
|
||||
const ProductContext = createContext<ProductContextValue | null>(null);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user