From a7cb866caba4c0d7f442571244e8e25a1d0812ef Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 29 May 2026 06:56:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(tracker-web):=20=E2=8C=98K=20command=20pal?= =?UTF-8?q?ette=20(UX-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @bytelyst/command-palette (workspace:* + minimal lockfile importer entry). Mount CommandRegistryProvider + a lazily-loaded CommandMenu in providers.tsx, opened with ⌘K / Ctrl-K. Register navigate commands (Overview/Items/Board/Roadmap), New item (navigates to items with ?new=1 which auto-opens the create modal), Toggle theme, Sign out, and per-product Switch commands wired to setProductId. Command building lives in the pure src/lib/command-registry.ts. Add command-menu.test.tsx (jsdom) asserting the builder set and that the palette opens on ⌘K and lists commands. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- dashboards/tracker-web/package.json | 1 + .../src/__tests__/command-menu.test.tsx | 102 ++++++++++++++++++ .../src/app/dashboard/items/page.tsx | 13 +++ dashboards/tracker-web/src/app/providers.tsx | 12 ++- .../src/components/command-menu.tsx | 44 ++++++++ .../tracker-web/src/lib/command-registry.ts | 74 +++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 dashboards/tracker-web/src/__tests__/command-menu.test.tsx create mode 100644 dashboards/tracker-web/src/components/command-menu.tsx create mode 100644 dashboards/tracker-web/src/lib/command-registry.ts diff --git a/dashboards/tracker-web/package.json b/dashboards/tracker-web/package.json index bd25c674..d06e2bad 100644 --- a/dashboards/tracker-web/package.json +++ b/dashboards/tracker-web/package.json @@ -27,6 +27,7 @@ "@bytelyst/api-client": "workspace:*", "@bytelyst/auth-ui": "workspace:*", "@bytelyst/charts": "workspace:*", + "@bytelyst/command-palette": "workspace:*", "@bytelyst/config": "workspace:*", "@bytelyst/dashboard-components": "workspace:*", "@bytelyst/data-table": "workspace:*", diff --git a/dashboards/tracker-web/src/__tests__/command-menu.test.tsx b/dashboards/tracker-web/src/__tests__/command-menu.test.tsx new file mode 100644 index 00000000..0bb6efbd --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/command-menu.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +/** + * UX-5.3 — command palette behaviour. + * + * Asserts the registry builder produces the expected command set, and that the + * palette opens on ⌘K and lists the registered commands. Uses a jsdom render + * harness with react-dom/client (no @testing-library dependency). + * + * @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-5) + */ + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + CommandRegistryProvider, + CommandPalette, + useCommandPalette, + useRegisterCommands, +} from '@bytelyst/command-palette'; +import { buildCommands, type CommandMenuDeps } from '@/lib/command-registry'; + +beforeAll(() => { + (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; +}); + +const stubDeps = (over: Partial = {}): CommandMenuDeps => ({ + navigate: vi.fn(), + newItem: vi.fn(), + toggleTheme: vi.fn(), + signOut: vi.fn(), + setProduct: vi.fn(), + products: [{ id: 'p1', name: 'Prod One' }], + ...over, +}); + +describe('buildCommands', () => { + it('includes navigate, action and per-product switch commands', () => { + const cmds = buildCommands(stubDeps()); + const ids = cmds.map(c => c.id); + expect(ids).toEqual( + expect.arrayContaining([ + 'nav-overview', + 'nav-items', + 'nav-board', + 'nav-roadmap', + 'new-item', + 'toggle-theme', + 'sign-out', + 'switch-product-p1', + ]) + ); + // Navigate commands carry an href + navigate mode. + const overview = cmds.find(c => c.id === 'nav-overview'); + expect(overview?.mode).toBe('navigate'); + expect(overview?.href).toBe('/dashboard'); + }); + + it('wires action handlers to the supplied callbacks', () => { + const deps = stubDeps(); + const cmds = buildCommands(deps); + cmds.find(c => c.id === 'new-item')?.run?.(); + cmds.find(c => c.id === 'switch-product-p1')?.run?.(); + expect(deps.newItem).toHaveBeenCalledTimes(1); + expect(deps.setProduct).toHaveBeenCalledWith('p1'); + }); +}); + +function Harness() { + const cmdk = useCommandPalette(); + useRegisterCommands(buildCommands(stubDeps())); + return createElement(CommandPalette, { open: cmdk.open, onClose: cmdk.hide }); +} + +describe('CommandPalette ⌘K', () => { + it('opens on Cmd-K and lists registered commands', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(createElement(CommandRegistryProvider, null, createElement(Harness))); + }); + + // Closed initially — the dialog is not rendered. + expect(container.querySelector('[data-testid="bl-cmdk"]')).toBeNull(); + + // ⌘K toggles it open. + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })); + }); + expect(container.querySelector('[data-testid="bl-cmdk"]')).not.toBeNull(); + + // Actions-tab commands are listed. + expect(container.textContent).toContain('New item'); + expect(container.textContent).toContain('Toggle theme'); + expect(container.textContent).toContain('Sign out'); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/dashboards/tracker-web/src/app/dashboard/items/page.tsx b/dashboards/tracker-web/src/app/dashboard/items/page.tsx index f11d17a2..b138db08 100644 --- a/dashboards/tracker-web/src/app/dashboard/items/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/items/page.tsx @@ -87,6 +87,19 @@ export default function ItemsListPage() { if (token) fetchItems(); }, [token, fetchItems]); + // The ⌘K "New item" command navigates here with ?new=1 — open the create + // modal and strip the param so a refresh doesn't reopen it (UX-5). + useEffect(() => { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + if (params.get('new') === '1') { + setShowCreate(true); + params.delete('new'); + const qs = params.toString(); + window.history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : '')); + } + }, []); + const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); try { diff --git a/dashboards/tracker-web/src/app/providers.tsx b/dashboards/tracker-web/src/app/providers.tsx index f3daab6e..2bbe92ec 100644 --- a/dashboards/tracker-web/src/app/providers.tsx +++ b/dashboards/tracker-web/src/app/providers.tsx @@ -1,6 +1,8 @@ 'use client'; import { useEffect, type ReactNode } from 'react'; +import dynamic from 'next/dynamic'; +import { CommandRegistryProvider } from '@bytelyst/command-palette'; import { AuthProvider } from '@/lib/auth-context'; import { ThemeProvider } from '@/lib/theme-context'; import { ProductProvider } from '@/lib/product-context'; @@ -8,6 +10,9 @@ import { initTelemetry } from '@/lib/telemetry'; import { CSPostHogProvider } from '@/components/posthog-provider'; +// ⌘K palette — loaded lazily so its code stays out of the initial bundle (UX-5). +const CommandMenu = dynamic(() => import('@/components/command-menu'), { ssr: false }); + export function Providers({ children }: { children: ReactNode }) { useEffect(() => { initTelemetry(); @@ -17,7 +22,12 @@ export function Providers({ children }: { children: ReactNode }) { - {children} + + + {children} + + + diff --git a/dashboards/tracker-web/src/components/command-menu.tsx b/dashboards/tracker-web/src/components/command-menu.tsx new file mode 100644 index 00000000..a27e8246 --- /dev/null +++ b/dashboards/tracker-web/src/components/command-menu.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { CommandPalette, useCommandPalette, useRegisterCommands } from '@bytelyst/command-palette'; +import { useTheme } from '@/lib/theme-context'; +import { useAuth } from '@/lib/auth-context'; +import { useProduct } from '@/lib/product-context'; +import { buildCommands } from '@/lib/command-registry'; + +/** + * ⌘K / Ctrl-K command palette shell (UX-5). Mounted once inside the providers + * tree (within CommandRegistryProvider) and loaded via next/dynamic so the + * palette code stays out of the initial bundle. + */ +export default function CommandMenu() { + const router = useRouter(); + const { theme, setTheme } = useTheme(); + const { logout } = useAuth(); + const { products, setProductId } = useProduct(); + const cmdk = useCommandPalette(); + + const commands = useMemo( + () => + buildCommands({ + navigate: href => router.push(href), + newItem: () => router.push('/dashboard/items?new=1'), + toggleTheme: () => setTheme(theme === 'dark' ? 'light' : 'dark'), + signOut: () => { + logout(); + router.push('/login'); + }, + setProduct: setProductId, + products, + }), + [router, theme, setTheme, logout, setProductId, products] + ); + + useRegisterCommands(commands); + + return ( + router.push(href)} /> + ); +} diff --git a/dashboards/tracker-web/src/lib/command-registry.ts b/dashboards/tracker-web/src/lib/command-registry.ts new file mode 100644 index 00000000..da731b37 --- /dev/null +++ b/dashboards/tracker-web/src/lib/command-registry.ts @@ -0,0 +1,74 @@ +/** + * Pure command-registry builder for the ⌘K command palette (UX-5). + * + * Kept free of React/Next imports so it is unit-testable in a node + * environment and reusable by the client `CommandMenu` shell. + * + * @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-5) + */ + +import type { Command } from '@bytelyst/command-palette'; + +export interface CommandMenuDeps { + /** Navigate to an in-app route. */ + navigate: (href: string) => void; + /** Open the "create item" flow. */ + newItem: () => void; + /** Flip the colour theme. */ + toggleTheme: () => void; + /** Sign the current user out. */ + signOut: () => void; + /** Switch the active product (wires ProductSwitcher). */ + setProduct: (id: string) => void; + /** Known products to expose as switch targets. */ + products: ReadonlyArray<{ id: string; name: string }>; +} + +const navCommand = (id: string, label: string, href: string): Command => ({ + id, + label, + mode: 'navigate', + href, + section: 'Navigate', + keywords: ['go', 'open', label.toLowerCase()], +}); + +/** Build the full command list for the palette from the supplied callbacks. */ +export function buildCommands(deps: CommandMenuDeps): Command[] { + return [ + navCommand('nav-overview', 'Overview', '/dashboard'), + navCommand('nav-items', 'Items', '/dashboard/items'), + navCommand('nav-board', 'Board', '/dashboard/board'), + navCommand('nav-roadmap', 'Roadmap', '/roadmap'), + { + id: 'new-item', + label: 'New item', + section: 'Actions', + keywords: ['create', 'add', 'item'], + run: deps.newItem, + }, + { + id: 'toggle-theme', + label: 'Toggle theme', + section: 'Actions', + keywords: ['dark', 'light', 'appearance'], + run: deps.toggleTheme, + }, + { + id: 'sign-out', + label: 'Sign out', + section: 'Actions', + keywords: ['logout', 'log out'], + run: deps.signOut, + }, + ...deps.products.map( + (p): Command => ({ + id: `switch-product-${p.id}`, + label: `Switch product: ${p.name}`, + section: 'Product', + keywords: ['product', 'switch', p.name.toLowerCase()], + run: () => deps.setProduct(p.id), + }) + ), + ]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40bb3a0a..a77938e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: '@bytelyst/charts': specifier: workspace:* version: link:../../packages/charts + '@bytelyst/command-palette': + specifier: workspace:* + version: link:../../packages/command-palette '@bytelyst/config': specifier: workspace:* version: link:../../packages/config