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:
parent
50704c6e45
commit
b4e450d68a
@ -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.
|
||||
test **18 files / 159 tests** (+1 file / +19); typecheck+lint+build green (123 routes);
|
||||
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
|
||||
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`/
|
||||
`NotFoundPage`/`LoadingSpinner`) on `error.tsx`/`not-found.tsx`/`loading.tsx` + a few high-traffic
|
||||
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 ✅
|
||||
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 ⬜
|
||||
```
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
"@bytelyst/api-client": "workspace:*",
|
||||
"@bytelyst/auth": "workspace:*",
|
||||
"@bytelyst/charts": "workspace:*",
|
||||
"@bytelyst/command-palette": "workspace:*",
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/cosmos": "workspace:*",
|
||||
"@bytelyst/dashboard-components": "workspace:*",
|
||||
|
||||
117
dashboards/admin-web/src/__tests__/command-palette.test.tsx
Normal file
117
dashboards/admin-web/src/__tests__/command-palette.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { CommandRegistryProvider } from '@bytelyst/command-palette';
|
||||
import { SidebarNav } from '@/components/sidebar-nav';
|
||||
import { AuthGuard } from '@/components/auth-guard';
|
||||
import { CommandMenu } from '@/components/command-menu';
|
||||
import { ErrorBoundary } from '@/components/error-boundary';
|
||||
import { useStripeConfig } from '@/lib/stripe-context';
|
||||
import { FlaskConical, ShieldCheck } from 'lucide-react';
|
||||
@ -32,6 +34,7 @@ function StripeModeBanner() {
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<CommandRegistryProvider>
|
||||
<SidebarNav />
|
||||
<main className="ml-64 min-h-screen bg-background max-md:ml-0">
|
||||
<StripeModeBanner />
|
||||
@ -39,6 +42,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
<CommandMenu />
|
||||
</CommandRegistryProvider>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
58
dashboards/admin-web/src/components/command-menu.tsx
Normal file
58
dashboards/admin-web/src/components/command-menu.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
102
dashboards/admin-web/src/lib/admin-commands.ts
Normal file
102
dashboards/admin-web/src/lib/admin-commands.ts
Normal 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];
|
||||
}
|
||||
@ -13,7 +13,7 @@ export default defineConfig({
|
||||
// The real Next/webpack build already dedupes these to admin-web's React.
|
||||
server: {
|
||||
deps: {
|
||||
inline: [/@bytelyst\/(charts|data-viz)/],
|
||||
inline: [/@bytelyst\/(charts|data-viz|command-palette)/],
|
||||
},
|
||||
},
|
||||
coverage: {
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -92,6 +92,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