feat(admin-web): add @bytelyst/command-palette (Cmd-K) to dashboard (UX-3)

- Mount CommandRegistryProvider in (dashboard)/layout.tsx and a CommandMenu
  that binds the global Cmd-K / Ctrl-K hotkey (useCommandPalette) and lazy-loads
  the dialog via next/dynamic (own chunk; dynamic target is a local re-export
  command-palette-dialog.tsx because the package declares only an `import`
  export condition).
- src/lib/admin-commands.ts: pure builder for 21 navigate-mode commands across
  the major surfaces (Users, Subscriptions, Licenses, Billing, Usage,
  Broadcasts, Flags, Experiments, Audit, Ops, …) plus theme-toggle and sign-out
  actions wired to the existing auth/theme contexts; onNavigate -> router.push.
- @bytelyst/command-palette added as workspace:* (importer-only lockfile change;
  --frozen-lockfile clean).
- vitest.config: inline command-palette + dedupe react for the interaction test.

Tests: pure command-set assertions + a happy-dom Cmd-K/Ctrl-K interaction test
(react-dom/client + act, no new deps).

Verify: typecheck+lint+build green (123 routes); vitest 19 files / 165 tests
(+6); format:check no new failures; e2e 11 passed / 80 failed (unchanged vs
UX-1 baseline — environmental, no backend).

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 13:58:49 -07:00
parent 50704c6e45
commit b4e450d68a
9 changed files with 313 additions and 10 deletions

View File

@ -142,9 +142,16 @@ pnpm --filter @bytelyst/admin-web test:e2e # Playwright + @axe-core (no new fa
`--frozen-lockfile` clean). Vitest config: inline + `dedupe` react for SSR render tests. `--frozen-lockfile` clean). Vitest config: inline + `dedupe` react for SSR render tests.
test **18 files / 159 tests** (+1 file / +19); typecheck+lint+build green (123 routes); test **18 files / 159 tests** (+1 file / +19); typecheck+lint+build green (123 routes);
format:check no new failures; e2e unchanged (see below). format:check no new failures; e2e unchanged (see below).
- [ ] **UX-3 — Command palette:** add `@bytelyst/command-palette`; mount `CommandRegistryProvider` + - [x] **UX-3 — Command palette:** add `@bytelyst/command-palette`; mount `CommandRegistryProvider` +
`CommandPalette` (⌘K, lazy) in `(dashboard)/layout.tsx`; register navigate commands for the major `CommandPalette` (⌘K, lazy) in `(dashboard)/layout.tsx`; register navigate commands for the major
surfaces (Users, Billing, Flags, Broadcasts, Audit, Experiments, Subscriptions, Licenses, Ops, …) + theme toggle + sign out; Vitest test palette opens on ⌘K. surfaces (Users, Billing, Flags, Broadcasts, Audit, Experiments, Subscriptions, Licenses, Ops, …) + theme toggle + sign out; Vitest test palette opens on ⌘K.
**DONE** `<SHA-UX3>` · `CommandRegistryProvider` wraps the dashboard; `CommandMenu`
(`useCommandPalette` ⌘K/Ctrl-K hotkey + lazy `next/dynamic` dialog via local
`command-palette-dialog.tsx` re-export) registers **21** navigate commands (`src/lib/admin-commands.ts`) + theme-toggle + sign-out actions; `onNavigate`→`router.push`. Dep added `workspace:*`
(importer-only lockfile change, `--frozen-lockfile` clean). Vitest: pure command-set tests +
happy-dom ⌘K/Ctrl-K interaction test (`react-dom/client` + `act`, no new deps; react deduped).
test **19 files / 165 tests** (+6); typecheck+lint+build green (123 routes); format:check no new
failures; e2e unchanged.
- [ ] **UX-4 — Page chrome:** use `@bytelyst/dashboard-components` (`PageHeader`/`ErrorPage`/ - [ ] **UX-4 — Page chrome:** use `@bytelyst/dashboard-components` (`PageHeader`/`ErrorPage`/
`NotFoundPage`/`LoadingSpinner`) on `error.tsx`/`not-found.tsx`/`loading.tsx` + a few high-traffic `NotFoundPage`/`LoadingSpinner`) on `error.tsx`/`not-found.tsx`/`loading.tsx` + a few high-traffic
surfaces where chrome is bespoke. Keep it additive. surfaces where chrome is bespoke. Keep it additive.
@ -166,7 +173,7 @@ pnpm --filter @bytelyst/admin-web test:e2e # Playwright + @axe-core (no new fa
``` ```
Setup : UX-1 ✅ Setup : UX-1 ✅
Adopt : UX-2 ✅ UX-3 UX-4 ⬜ UX-5 ⬜ UX-6 ⬜(cond) Adopt : UX-2 ✅ UX-3 UX-4 ⬜ UX-5 ⬜ UX-6 ⬜(cond)
Cross : CC.1 ⬜ CC.2 ⬜ CC.3 ⬜ CC.4 ⬜ CC.5 ⬜ CC.6 ⬜ Cross : CC.1 ⬜ CC.2 ⬜ CC.3 ⬜ CC.4 ⬜ CC.5 ⬜ CC.6 ⬜
``` ```

View File

@ -27,6 +27,7 @@
"@bytelyst/api-client": "workspace:*", "@bytelyst/api-client": "workspace:*",
"@bytelyst/auth": "workspace:*", "@bytelyst/auth": "workspace:*",
"@bytelyst/charts": "workspace:*", "@bytelyst/charts": "workspace:*",
"@bytelyst/command-palette": "workspace:*",
"@bytelyst/config": "workspace:*", "@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*", "@bytelyst/cosmos": "workspace:*",
"@bytelyst/dashboard-components": "workspace:*", "@bytelyst/dashboard-components": "workspace:*",

View File

@ -0,0 +1,117 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useCommandPalette } from '@bytelyst/command-palette';
import { buildAdminCommands, NAV_COMMANDS } from '@/lib/admin-commands';
/**
* UX-3 command palette guard.
*
* 1. `buildAdminCommands` produces the expected navigate + action set, and the
* action callbacks are correctly wired.
* 2. The K / Ctrl-K hotkey (via `useCommandPalette`) toggles the palette open.
*/
describe('buildAdminCommands', () => {
const base = () => buildAdminCommands({ resolvedTheme: 'light', toggleTheme() {}, signOut() {} });
it('includes navigate commands for the major surfaces', () => {
const cmds = base();
const navHrefs = cmds.filter(c => c.mode === 'navigate').map(c => c.href);
for (const href of [
'/users',
'/billing',
'/flags',
'/broadcasts',
'/audit',
'/experiments',
'/subscriptions',
'/licenses',
'/ops',
]) {
expect(navHrefs).toContain(href);
}
expect(cmds.filter(c => c.mode === 'navigate')).toHaveLength(NAV_COMMANDS.length);
});
it('exposes theme-toggle + sign-out actions whose run callbacks fire', () => {
const toggleTheme = vi.fn();
const signOut = vi.fn();
const cmds = buildAdminCommands({ resolvedTheme: 'dark', toggleTheme, signOut });
const theme = cmds.find(c => c.id === 'action-toggle-theme');
const out = cmds.find(c => c.id === 'action-sign-out');
expect(theme?.label).toBe('Switch to light mode'); // dark -> offer light
expect(out?.mode).toBe('actions');
theme?.run?.();
out?.run?.();
expect(toggleTheme).toHaveBeenCalledTimes(1);
expect(signOut).toHaveBeenCalledTimes(1);
});
it('flips the theme label based on the resolved theme', () => {
const light = buildAdminCommands({ resolvedTheme: 'light', toggleTheme() {}, signOut() {} });
expect(light.find(c => c.id === 'action-toggle-theme')?.label).toBe('Switch to dark mode');
});
});
describe('⌘K hotkey opens the palette', () => {
let container: HTMLDivElement;
let root: Root;
function Harness() {
const cmdk = useCommandPalette();
return <span data-testid="state">{cmdk.open ? 'open' : 'closed'}</span>;
}
beforeEach(() => {
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function press(init: KeyboardEventInit) {
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }));
});
}
it('toggles open on Cmd-K and closed again', () => {
act(() => {
root.render(<Harness />);
});
expect(container.textContent).toBe('closed');
press({ key: 'k', metaKey: true });
expect(container.textContent).toBe('open');
press({ key: 'k', metaKey: true });
expect(container.textContent).toBe('closed');
});
it('also opens on Ctrl-K', () => {
act(() => {
root.render(<Harness />);
});
press({ key: 'k', ctrlKey: true });
expect(container.textContent).toBe('open');
});
it('ignores unmodified k and other keys', () => {
act(() => {
root.render(<Harness />);
});
press({ key: 'k' });
press({ key: 'j', metaKey: true });
expect(container.textContent).toBe('closed');
});
});

View File

@ -1,7 +1,9 @@
'use client'; 'use client';
import { CommandRegistryProvider } from '@bytelyst/command-palette';
import { SidebarNav } from '@/components/sidebar-nav'; import { SidebarNav } from '@/components/sidebar-nav';
import { AuthGuard } from '@/components/auth-guard'; import { AuthGuard } from '@/components/auth-guard';
import { CommandMenu } from '@/components/command-menu';
import { ErrorBoundary } from '@/components/error-boundary'; import { ErrorBoundary } from '@/components/error-boundary';
import { useStripeConfig } from '@/lib/stripe-context'; import { useStripeConfig } from '@/lib/stripe-context';
import { FlaskConical, ShieldCheck } from 'lucide-react'; import { FlaskConical, ShieldCheck } from 'lucide-react';
@ -32,13 +34,16 @@ function StripeModeBanner() {
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return ( return (
<AuthGuard> <AuthGuard>
<SidebarNav /> <CommandRegistryProvider>
<main className="ml-64 min-h-screen bg-background max-md:ml-0"> <SidebarNav />
<StripeModeBanner /> <main className="ml-64 min-h-screen bg-background max-md:ml-0">
<div className="p-8 max-md:p-4"> <StripeModeBanner />
<ErrorBoundary>{children}</ErrorBoundary> <div className="p-8 max-md:p-4">
</div> <ErrorBoundary>{children}</ErrorBoundary>
</main> </div>
</main>
<CommandMenu />
</CommandRegistryProvider>
</AuthGuard> </AuthGuard>
); );
} }

View File

@ -0,0 +1,58 @@
'use client';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { useCommandPalette, useRegisterCommands } from '@bytelyst/command-palette';
import { useAuth } from '@/lib/auth-context';
import { useTheme } from '@/lib/theme-context';
import { buildAdminCommands } from '@/lib/admin-commands';
// Lazy-load the dialog itself (own chunk, client only) — see
// command-palette-dialog.tsx for why the dynamic target is a local re-export.
const CommandPalette = dynamic(
() => import('./command-palette-dialog').then(m => ({ default: m.CommandPalette })),
{ ssr: false }
);
/**
* Mounts the K command palette for the dashboard (UX-3). Registers
* navigate-mode commands for the major surfaces plus theme-toggle / sign-out
* actions, and binds the global K / Ctrl-K hotkey via `useCommandPalette`.
*
* Must render inside a `<CommandRegistryProvider>` (mounted in the dashboard
* layout) so `useRegisterCommands` and the dialog share one registry.
*/
export function CommandMenu() {
const router = useRouter();
const { logout } = useAuth();
const { resolved, setTheme } = useTheme();
const cmdk = useCommandPalette();
const commands = useMemo(
() =>
buildAdminCommands({
resolvedTheme: resolved,
toggleTheme: () => setTheme(resolved === 'dark' ? 'light' : 'dark'),
signOut: () => {
logout();
router.replace('/login');
},
}),
[resolved, setTheme, logout, router]
);
useRegisterCommands(commands);
return (
<CommandPalette
open={cmdk.open}
onClose={cmdk.hide}
ariaLabel="Admin command palette"
onNavigate={href => {
cmdk.hide();
router.push(href);
}}
/>
);
}

View File

@ -0,0 +1,10 @@
'use client';
/**
* Static re-export seam for the heavy `<CommandPalette>` dialog so it can be
* code-split via `next/dynamic` from `command-menu.tsx`. As with the charts
* seam, the dynamic-import target must be a *local* module: a direct
* `import('@bytelyst/command-palette')` trips Next's package-`exports`
* resolver (the package declares only an `import` condition).
*/
export { CommandPalette } from '@bytelyst/command-palette';

View File

@ -0,0 +1,102 @@
/**
* Command registry contributions for the admin K palette (UX-3).
*
* Kept framework-free (pure data + callbacks) so the command set is
* unit-testable in the node vitest env without rendering anything.
*/
import type { Command } from '@bytelyst/command-palette';
/** Curated navigate targets — the major admin surfaces. */
export const NAV_COMMANDS: ReadonlyArray<{
id: string;
label: string;
href: string;
keywords?: string[];
}> = [
{ id: 'nav-dashboard', label: 'Dashboard', href: '/', keywords: ['home', 'overview'] },
{ id: 'nav-users', label: 'Users', href: '/users', keywords: ['accounts', 'people'] },
{
id: 'nav-subscriptions',
label: 'Subscriptions',
href: '/subscriptions',
keywords: ['plans', 'billing'],
},
{ id: 'nav-licenses', label: 'Licenses', href: '/licenses', keywords: ['seats'] },
{ id: 'nav-billing', label: 'Billing', href: '/billing', keywords: ['invoices', 'payments'] },
{ id: 'nav-usage', label: 'Usage Analytics', href: '/usage', keywords: ['metrics', 'tokens'] },
{ id: 'nav-broadcasts', label: 'Broadcasts', href: '/broadcasts', keywords: ['announcements'] },
{ id: 'nav-surveys', label: 'Surveys', href: '/surveys', keywords: ['feedback', 'nps'] },
{ id: 'nav-flags', label: 'Feature Flags', href: '/flags', keywords: ['toggles', 'rollout'] },
{
id: 'nav-experiments',
label: 'Experiments',
href: '/experiments',
keywords: ['ab', 'a/b', 'tests'],
},
{ id: 'nav-audit', label: 'Audit Log', href: '/audit', keywords: ['history', 'events'] },
{ id: 'nav-products', label: 'Products', href: '/products' },
{ id: 'nav-invitations', label: 'Invitations', href: '/invitations', keywords: ['invites'] },
{ id: 'nav-promos', label: 'Promo Codes', href: '/promos', keywords: ['coupons', 'discounts'] },
{ id: 'nav-referrals', label: 'Referrals', href: '/referrals' },
{
id: 'nav-notifications',
label: 'Notifications',
href: '/notifications',
keywords: ['alerts'],
},
{ id: 'nav-organizations', label: 'Organizations', href: '/organizations', keywords: ['orgs'] },
{ id: 'nav-ops', label: 'Mission Control', href: '/ops', keywords: ['ops', 'operations'] },
{
id: 'nav-client-logs',
label: 'Client Logs',
href: '/ops/client-logs',
keywords: ['telemetry', 'errors'],
},
{ id: 'nav-extraction', label: 'Extraction', href: '/extraction' },
{ id: 'nav-settings', label: 'Settings', href: '/settings', keywords: ['config', 'preferences'] },
];
export interface AdminCommandActions {
/** Theme-aware label uses this to read the current mode. */
resolvedTheme?: 'light' | 'dark';
/** Toggle light/dark theme. */
toggleTheme: () => void;
/** Sign the admin out and return to /login. */
signOut: () => void;
}
/**
* Build the full admin command set: navigate-mode entries for the major
* surfaces plus the app-chrome actions (theme toggle, sign out).
*/
export function buildAdminCommands(actions: AdminCommandActions): Command[] {
const navigate: Command[] = NAV_COMMANDS.map(n => ({
id: n.id,
label: n.label,
mode: 'navigate',
href: n.href,
section: 'Go to',
keywords: n.keywords,
}));
const chrome: Command[] = [
{
id: 'action-toggle-theme',
label: actions.resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode',
mode: 'actions',
section: 'Preferences',
keywords: ['theme', 'dark', 'light', 'appearance'],
run: actions.toggleTheme,
},
{
id: 'action-sign-out',
label: 'Sign out',
mode: 'actions',
section: 'Account',
keywords: ['logout', 'log out', 'exit'],
run: actions.signOut,
},
];
return [...navigate, ...chrome];
}

View File

@ -13,7 +13,7 @@ export default defineConfig({
// The real Next/webpack build already dedupes these to admin-web's React. // The real Next/webpack build already dedupes these to admin-web's React.
server: { server: {
deps: { deps: {
inline: [/@bytelyst\/(charts|data-viz)/], inline: [/@bytelyst\/(charts|data-viz|command-palette)/],
}, },
}, },
coverage: { coverage: {

3
pnpm-lock.yaml generated
View File

@ -92,6 +92,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