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:
parent
dfdc9777c4
commit
a7cb866cab
@ -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:*",
|
||||
|
||||
102
dashboards/tracker-web/src/__tests__/command-menu.test.tsx
Normal file
102
dashboards/tracker-web/src/__tests__/command-menu.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
44
dashboards/tracker-web/src/components/command-menu.tsx
Normal file
44
dashboards/tracker-web/src/components/command-menu.tsx
Normal 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)} />
|
||||
);
|
||||
}
|
||||
74
dashboards/tracker-web/src/lib/command-registry.ts
Normal file
74
dashboards/tracker-web/src/lib/command-registry.ts
Normal 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
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user