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
|
PRODUCT_ID=lysnrai
|
||||||
NEXT_PUBLIC_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) ──
|
# ── Microservice URLs (consolidated platform-service) ──
|
||||||
PLATFORM_SERVICE_URL=http://localhost:4003
|
PLATFORM_SERVICE_URL=http://localhost:4003
|
||||||
PLATFORM_API_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() {
|
export function ProductSwitcher() {
|
||||||
const { productId, setProductId, products } = useProduct();
|
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 (
|
return (
|
||||||
<select
|
<select
|
||||||
value={productId}
|
value={productId}
|
||||||
|
|||||||
@ -10,6 +10,14 @@ import type { NextRequest } from 'next/server';
|
|||||||
|
|
||||||
const identity = loadProductIdentity();
|
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 PRODUCT_ID = identity.productId;
|
||||||
export const DISPLAY_NAME = identity.displayName;
|
export const DISPLAY_NAME = identity.displayName;
|
||||||
export const LICENSE_PREFIX = identity.licensePrefix;
|
export const LICENSE_PREFIX = identity.licensePrefix;
|
||||||
@ -22,11 +30,3 @@ export const PACKAGE_NAME = identity.packageName;
|
|||||||
export function getRequestProductId(req: NextRequest): string {
|
export function getRequestProductId(req: NextRequest): string {
|
||||||
return req.headers.get('x-product-id') || PRODUCT_ID;
|
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
|
* Use this in 'use client' components. For server-side code that needs
|
||||||
* loadProductIdentity(), import from product-config.ts instead.
|
* 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 PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || process.env.PRODUCT_ID || 'lysnrai';
|
||||||
export const DISPLAY_NAME = process.env.NEXT_PUBLIC_DISPLAY_NAME || 'LysnrAI';
|
export const DISPLAY_NAME = process.env.NEXT_PUBLIC_DISPLAY_NAME || 'LysnrAI';
|
||||||
|
|
||||||
/** All known products in the ByteLyst ecosystem. */
|
/** A selectable product/project the dashboard can be scoped to. */
|
||||||
export const KNOWN_PRODUCTS = [
|
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: 'lysnrai', name: 'LysnrAI', icon: 'Mic' },
|
||||||
{ id: 'chronomind', name: 'ChronoMind', icon: 'Clock' },
|
{ id: 'chronomind', name: 'ChronoMind', icon: 'Clock' },
|
||||||
{ id: 'nomgap', name: 'NomGap', icon: 'Apple' },
|
{ id: 'nomgap', name: 'NomGap', icon: 'Apple' },
|
||||||
{ id: 'mindlyst', name: 'MindLyst', icon: 'Brain' },
|
{ 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';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, useEffect, 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, type Product } from '@/lib/product-constants';
|
||||||
|
|
||||||
const STORAGE_KEY = 'tracker_selected_product';
|
const STORAGE_KEY = 'tracker_selected_product';
|
||||||
const PRODUCT_CHANGED_EVENT = 'tracker:product-changed';
|
const PRODUCT_CHANGED_EVENT = 'tracker:product-changed';
|
||||||
@ -10,7 +10,7 @@ interface ProductContextValue {
|
|||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
setProductId: (id: string) => void;
|
setProductId: (id: string) => void;
|
||||||
products: typeof KNOWN_PRODUCTS;
|
products: readonly Product[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductContext = createContext<ProductContextValue | null>(null);
|
const ProductContext = createContext<ProductContextValue | null>(null);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user