feat(tracker-web): ⌘K command palette (UX-5)

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>
This commit is contained in:
saravanakumardb1 2026-05-29 06:56:11 -07:00
parent dfdc9777c4
commit a7cb866cab
7 changed files with 248 additions and 1 deletions

View File

@ -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:*",

View File

@ -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<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
});
const stubDeps = (over: Partial<CommandMenuDeps> = {}): 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();
});
});

View File

@ -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 {

View File

@ -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 }) {
<CSPostHogProvider>
<ThemeProvider>
<ProductProvider>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
<CommandRegistryProvider>
{children}
<CommandMenu />
</CommandRegistryProvider>
</AuthProvider>
</ProductProvider>
</ThemeProvider>
</CSPostHogProvider>

View File

@ -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 (
<CommandPalette open={cmdk.open} onClose={cmdk.hide} onNavigate={href => router.push(href)} />
);
}

View File

@ -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),
})
),
];
}

3
pnpm-lock.yaml generated
View File

@ -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