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/api-client": "workspace:*",
|
||||||
"@bytelyst/auth-ui": "workspace:*",
|
"@bytelyst/auth-ui": "workspace:*",
|
||||||
"@bytelyst/charts": "workspace:*",
|
"@bytelyst/charts": "workspace:*",
|
||||||
|
"@bytelyst/command-palette": "workspace:*",
|
||||||
"@bytelyst/config": "workspace:*",
|
"@bytelyst/config": "workspace:*",
|
||||||
"@bytelyst/dashboard-components": "workspace:*",
|
"@bytelyst/dashboard-components": "workspace:*",
|
||||||
"@bytelyst/data-table": "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();
|
if (token) fetchItems();
|
||||||
}, [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) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { CommandRegistryProvider } from '@bytelyst/command-palette';
|
||||||
import { AuthProvider } from '@/lib/auth-context';
|
import { AuthProvider } from '@/lib/auth-context';
|
||||||
import { ThemeProvider } from '@/lib/theme-context';
|
import { ThemeProvider } from '@/lib/theme-context';
|
||||||
import { ProductProvider } from '@/lib/product-context';
|
import { ProductProvider } from '@/lib/product-context';
|
||||||
@ -8,6 +10,9 @@ import { initTelemetry } from '@/lib/telemetry';
|
|||||||
|
|
||||||
import { CSPostHogProvider } from '@/components/posthog-provider';
|
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 }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initTelemetry();
|
initTelemetry();
|
||||||
@ -17,7 +22,12 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
<CSPostHogProvider>
|
<CSPostHogProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ProductProvider>
|
<ProductProvider>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>
|
||||||
|
<CommandRegistryProvider>
|
||||||
|
{children}
|
||||||
|
<CommandMenu />
|
||||||
|
</CommandRegistryProvider>
|
||||||
|
</AuthProvider>
|
||||||
</ProductProvider>
|
</ProductProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</CSPostHogProvider>
|
</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':
|
'@bytelyst/charts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/charts
|
version: link:../../packages/charts
|
||||||
|
'@bytelyst/command-palette':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/command-palette
|
||||||
'@bytelyst/config':
|
'@bytelyst/config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/config
|
version: link:../../packages/config
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user