diff --git a/dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md b/dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md index c3ba3476..546f5b4c 100644 --- a/dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md +++ b/dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md @@ -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** `` · `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 ⬜ ``` diff --git a/dashboards/admin-web/package.json b/dashboards/admin-web/package.json index 2a5134dd..ddf9218d 100644 --- a/dashboards/admin-web/package.json +++ b/dashboards/admin-web/package.json @@ -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:*", diff --git a/dashboards/admin-web/src/__tests__/command-palette.test.tsx b/dashboards/admin-web/src/__tests__/command-palette.test.tsx new file mode 100644 index 00000000..1a72e9b7 --- /dev/null +++ b/dashboards/admin-web/src/__tests__/command-palette.test.tsx @@ -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 {cmdk.open ? 'open' : 'closed'}; + } + + 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(); + }); + 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(); + }); + press({ key: 'k', ctrlKey: true }); + expect(container.textContent).toBe('open'); + }); + + it('ignores unmodified k and other keys', () => { + act(() => { + root.render(); + }); + press({ key: 'k' }); + press({ key: 'j', metaKey: true }); + expect(container.textContent).toBe('closed'); + }); +}); diff --git a/dashboards/admin-web/src/app/(dashboard)/layout.tsx b/dashboards/admin-web/src/app/(dashboard)/layout.tsx index 1c082708..acb905db 100644 --- a/dashboards/admin-web/src/app/(dashboard)/layout.tsx +++ b/dashboards/admin-web/src/app/(dashboard)/layout.tsx @@ -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,13 +34,16 @@ function StripeModeBanner() { export default function DashboardLayout({ children }: { children: React.ReactNode }) { return ( - -
- -
- {children} -
-
+ + +
+ +
+ {children} +
+
+ +
); } diff --git a/dashboards/admin-web/src/components/command-menu.tsx b/dashboards/admin-web/src/components/command-menu.tsx new file mode 100644 index 00000000..6636b756 --- /dev/null +++ b/dashboards/admin-web/src/components/command-menu.tsx @@ -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 `` (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 ( + { + cmdk.hide(); + router.push(href); + }} + /> + ); +} diff --git a/dashboards/admin-web/src/components/command-palette-dialog.tsx b/dashboards/admin-web/src/components/command-palette-dialog.tsx new file mode 100644 index 00000000..b1b768c4 --- /dev/null +++ b/dashboards/admin-web/src/components/command-palette-dialog.tsx @@ -0,0 +1,10 @@ +'use client'; + +/** + * Static re-export seam for the heavy `` 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'; diff --git a/dashboards/admin-web/src/lib/admin-commands.ts b/dashboards/admin-web/src/lib/admin-commands.ts new file mode 100644 index 00000000..28c61d88 --- /dev/null +++ b/dashboards/admin-web/src/lib/admin-commands.ts @@ -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]; +} diff --git a/dashboards/admin-web/vitest.config.ts b/dashboards/admin-web/vitest.config.ts index d133e4d7..882cbcbc 100644 --- a/dashboards/admin-web/vitest.config.ts +++ b/dashboards/admin-web/vitest.config.ts @@ -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: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8b420d4..15b58758 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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