From 6dc756fffed9bfb56074c9f0434421812d36cb42 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:56:49 -0400 Subject: [PATCH] FE: Add claude rules and FE patterns around command palette (#46826) --- .claude/rules/fleet-frontend.md | 3 + .claude/skills/command-palette/SKILL.md | 42 +++++ .../CommandPalette/CommandPalette.tests.tsx | 12 +- .../CommandPalette/CommandPalette.tsx | 30 ++-- .../CommandPalette/groups/commands.ts | 12 +- .../CommandPalette/groups/controls.ts | 4 +- frontend/components/CommandPalette/helpers.ts | 6 +- frontend/docs/patterns.md | 169 ++++++++++++++++++ 8 files changed, 248 insertions(+), 30 deletions(-) create mode 100644 .claude/skills/command-palette/SKILL.md diff --git a/.claude/rules/fleet-frontend.md b/.claude/rules/fleet-frontend.md index 3b13570829..f3af2cafe4 100644 --- a/.claude/rules/fleet-frontend.md +++ b/.claude/rules/fleet-frontend.md @@ -115,6 +115,9 @@ Editing inside already-gated code (adding a field to a premium-only form, fixing - "Teams" are now called "fleets" in the product. Code still uses `team_id`, `useTeamIdParam`, `permissions.isTeamAdmin`, etc. — don't rename existing APIs, but use "fleet" in new user-facing strings and comments. - "Queries" are now called "reports." The word "query" now refers solely to a SQL query. Code still uses `useQuery`, `queryKey`, etc. for React Query — that's unrelated to the product terminology change. +## Command palette +If you edit `frontend/router/paths.ts` or `frontend/router/index.tsx`, add a new MDM connector / singleton config, add a new global create / automation / settings action, or add a new picker action, load the `command-palette` skill before finishing — these changes almost always need a matching entry under `frontend/components/CommandPalette/groups/`. The palette is for navigation and global actions — not per-entity (row-level) operations, bulk-select actions, or per-view UI toggles. + ## Linting & Formatting - ESLint: extends airbnb + typescript-eslint + prettier - Prettier: default config (`.prettierrc.json`) diff --git a/.claude/skills/command-palette/SKILL.md b/.claude/skills/command-palette/SKILL.md new file mode 100644 index 0000000000..ef1b4b10a1 --- /dev/null +++ b/.claude/skills/command-palette/SKILL.md @@ -0,0 +1,42 @@ +--- +name: command-palette +description: Authoring guide for the Fleet command palette. Use when adding or editing items in frontend/components/CommandPalette/groups/, when editing frontend/router/paths.ts or frontend/router/index.tsx, or when adding a new top-level page, global create action, MDM connector / singleton config, automation hook, or picker action that needs a palette entry. +allowed-tools: Read, Grep, Glob, Bash(yarn test*) +effort: medium +--- + +# Command palette authoring + +The canonical guide lives in **`frontend/docs/patterns.md` § Command palette** — read that first. It covers what belongs (and what doesn't), the group-to-file mapping, required/optional fields, label conventions, the full keyword/synonym checklist, permission gating, `teamName` chips, search-only items, and test expectations. + +This skill exists to make sure that guide gets followed when palette-worthy changes land. + +## Before adding an item + +1. **Read `frontend/docs/patterns.md` § Command palette** end to end. +2. **Grep the target group file** for similar existing items. Match their shape — field order, keyword style, gating, `teamName` helper usage — instead of inventing a new pattern. The groups are the source of truth for current conventions: + ``` + frontend/components/CommandPalette/groups/ + ``` +3. **Confirm the destination page's own permission check**, then mirror it on the palette item using a flag from `ICommandPaletteContext` (`frontend/components/CommandPalette/helpers.ts`). Add a new flag there only if no existing one models the destination's check. Don't route users to a screen they can't use. + +## After adding an item + +1. **Update `frontend/components/CommandPalette/helpers.tests.ts`**: + - Assert the item appears for the right roles and hides for the wrong ones + - If premium-only: assert absence in the `Fleet Free (isPremiumTier: false)` block + - If hidden in primo mode: add to the `Primo Mode (isPrimoMode: true)` block + - If it sets a `teamName` chip: assert it renders / doesn't render against the relevant fleet contexts +2. Run the palette tests: + ``` + yarn test frontend/components/CommandPalette/helpers.tests.ts + ``` + (Note: test files use `.tests.ts` plural, and `yarn test` — not `yarn jest` — uses the project's jest config.) + +## When *not* to add an entry + +- Per-entity edit / delete operations (the entity is already in scope on its row or detail page) +- Bulk-select operations that depend on an existing selection +- One-off UI affordances (toggles, expanders) tied to a single view + +See patterns.md for the dividing line and the full "doesn't belong" list. diff --git a/frontend/components/CommandPalette/CommandPalette.tests.tsx b/frontend/components/CommandPalette/CommandPalette.tests.tsx index 1fa5e1cbb7..edad92cf62 100644 --- a/frontend/components/CommandPalette/CommandPalette.tests.tsx +++ b/frontend/components/CommandPalette/CommandPalette.tests.tsx @@ -247,7 +247,7 @@ describe("CommandPalette", () => { }); describe("Keyboard shortcuts", () => { - it("opens the switch-fleet sub-page on Cmd+Shift+F", async () => { + it("opens the switch-fleet picker page on Cmd+Shift+F", async () => { const { user } = adminRender(); await openPalette(user); @@ -303,11 +303,11 @@ describe("CommandPalette", () => { ).not.toBeInTheDocument(); }); - it("Escape returns to root from a sub-page instead of closing", async () => { + it("Escape returns to root from a picker page instead of closing", async () => { const { user } = adminRender(); await openPalette(user); - // Navigate into the switch-fleet sub-page + // Navigate into the switch-fleet picker page await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}"); await waitFor(() => { expect( @@ -324,12 +324,12 @@ describe("CommandPalette", () => { }); }); - it("Escape returns to root from a picker sub-page (view-host)", async () => { + it("Escape returns to root from a picker page (view-host)", async () => { const { user } = adminRender(); await openPalette(user); // The root page lists commands; find "View host" and activate it - // to reach the view-host sub-page. + // to reach the view-host picker page. const viewHost = await screen.findByText("View host"); await user.click(viewHost); @@ -360,7 +360,7 @@ describe("CommandPalette", () => { }); }); - it("Backspace on empty input goes back from a sub-page", async () => { + it("Backspace on empty input goes back from a picker page", async () => { const { user } = adminRender(); await openPalette(user); await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}"); diff --git a/frontend/components/CommandPalette/CommandPalette.tsx b/frontend/components/CommandPalette/CommandPalette.tsx index 5d16e0964f..0a742d4deb 100644 --- a/frontend/components/CommandPalette/CommandPalette.tsx +++ b/frontend/components/CommandPalette/CommandPalette.tsx @@ -222,7 +222,7 @@ const CommandPalette = (): JSX.Element | null => { typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform); - const subPagePlaceholders: Partial> = { + const pickerPagePlaceholders: Partial> = { "switch-fleet": "Search a fleet...", "view-host": "Search hosts...", "view-software": "Search software inventory...", @@ -230,7 +230,7 @@ const CommandPalette = (): JSX.Element | null => { "view-report": "Search reports...", "view-policy": "Search policies...", }; - const subPagePlaceholder = subPagePlaceholders[page]; + const pickerPagePlaceholder = pickerPagePlaceholders[page]; // Toggle open on Cmd+K / Ctrl+K; jump to switch-fleet on Cmd+Shift+F. // Focus is handled by the [open, page] effect below — don't rAF here, the @@ -286,7 +286,7 @@ const CommandPalette = (): JSX.Element | null => { } }, [open, page]); - // Backspace on empty input returns to root from a sub-page. + // Backspace on empty input returns to root from a picker page. const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (page !== "root" && e.key === "Backspace" && !search) { @@ -297,13 +297,13 @@ const CommandPalette = (): JSX.Element | null => { [page, search, goBack] ); - // Intercept Escape on a sub-page so it returns to root instead of + // Intercept Escape on a picker page so it returns to root instead of // closing the dialog. cmdk 1.1.1's Command.Dialog doesn't forward // `onEscapeKeyDown` to Radix's Dialog.Content, so we can't override // the close intent via props. // // Approach: a capture-phase document listener that calls - // `stopImmediatePropagation` on Escape from a sub-page. This prevents + // `stopImmediatePropagation` on Escape from a picker page. This prevents // both Radix's DismissableLayer ESC handler AND any sibling listeners // from firing on this event — the dialog never learns about the press, // so it doesn't close. `useLayoutEffect` is intentional: it attaches @@ -535,7 +535,7 @@ const CommandPalette = (): JSX.Element | null => { /> )} - {item.opensSubPage && ( + {item.opensPickerPage && ( { - {/* Render the sub-page chevron for items that open a - picker (View host, View software, etc.). The + {/* Render the picker-page chevron for items that open + a picker (View host, View software, etc.). The chevron only belongs to parent items — sub-items navigate directly. */} - {!sub && item.opensSubPage && ( + {!sub && item.opensPickerPage && ( { { )} {page !== "root" && ESC} - {/* Announce sub-page transitions to screen readers — the placeholder + {/* Announce picker-page transitions to screen readers — the placeholder text changes but isn't reliably announced on its own. Strip the trailing ellipsis so the announcement isn't verbalized as "dot dot dot" by some screen readers. */}
- {page === "root" ? "" : subPagePlaceholder?.replace(/\.{3}$/, "") ?? ""} + {page === "root" + ? "" + : pickerPagePlaceholder?.replace(/\.{3}$/, "") ?? ""}
- {/* Sub-pages render their own contextual empty state, so only show + {/* Picker pages render their own contextual empty state, so only show cmdk's generic Empty on the root page. */} {page === "root" && ( diff --git a/frontend/components/CommandPalette/groups/commands.ts b/frontend/components/CommandPalette/groups/commands.ts index 94b41a3f72..5f6840f34e 100644 --- a/frontend/components/CommandPalette/groups/commands.ts +++ b/frontend/components/CommandPalette/groups/commands.ts @@ -49,7 +49,7 @@ const buildCommandsItems = ( ] : []), - // View commands — open sub-pages with searchable lists. Placed at + // View commands — open picker pages with searchable lists. Placed at // the top of the Commands group so view actions appear before write // actions like Add hosts within this group. { @@ -68,7 +68,7 @@ const buildCommandsItems = ( "search hosts", ], onAction: onViewHost, - opensSubPage: true, + opensPickerPage: true, }, { id: "view-software", @@ -88,7 +88,7 @@ const buildCommandsItems = ( "inventory", ], onAction: onViewSoftware, - opensSubPage: true, + opensPickerPage: true, }, // View software library — Premium-only and hidden on "All fleets" since // libraries are per-fleet. @@ -114,7 +114,7 @@ const buildCommandsItems = ( "search library", ], onAction: onViewSoftwareLibrary, - opensSubPage: true, + opensPickerPage: true, }, ] : []), @@ -134,7 +134,7 @@ const buildCommandsItems = ( "search reports", ], onAction: onViewReport, - opensSubPage: true, + opensPickerPage: true, }, { id: "view-policy", @@ -151,7 +151,7 @@ const buildCommandsItems = ( "search policies", ], onAction: onViewPolicy, - opensSubPage: true, + opensPickerPage: true, }, // Actions — users who can write diff --git a/frontend/components/CommandPalette/groups/controls.ts b/frontend/components/CommandPalette/groups/controls.ts index e073586861..af09fa5ab8 100644 --- a/frontend/components/CommandPalette/groups/controls.ts +++ b/frontend/components/CommandPalette/groups/controls.ts @@ -32,7 +32,7 @@ const buildControlsItems = ( "patch", ], }, - // OS settings sub-pages + // OS settings sub-routes { id: "controls-os-settings", label: "OS settings", @@ -101,7 +101,7 @@ const buildControlsItems = ( : []), ], }, - // Setup experience sub-pages — Premium-only. + // Setup experience sub-routes — Premium-only. ...(isPremiumTier ? [ { diff --git a/frontend/components/CommandPalette/helpers.ts b/frontend/components/CommandPalette/helpers.ts index f3fcdd2204..c3cdb37414 100644 --- a/frontend/components/CommandPalette/helpers.ts +++ b/frontend/components/CommandPalette/helpers.ts @@ -21,7 +21,7 @@ export interface ICommandSubItem { export interface ICommandItem { id: string; label: string; - group: string; + group: typeof GROUPS[number]; path?: string; keywords?: string[]; /** Displayed on the right when navigating would switch your team context */ @@ -30,8 +30,8 @@ export interface ICommandItem { subItems?: ICommandSubItem[]; /** Custom action instead of navigation */ onAction?: () => void; - /** True when selecting this item opens a sub-page (not a navigation). */ - opensSubPage?: boolean; + /** True when selecting this item opens a picker page (not a navigation). */ + opensPickerPage?: boolean; } export interface ICommandPaletteContext { diff --git a/frontend/docs/patterns.md b/frontend/docs/patterns.md index ed08ffab76..98bd03efdd 100644 --- a/frontend/docs/patterns.md +++ b/frontend/docs/patterns.md @@ -17,6 +17,7 @@ should be discussed within the team and documented before merged. - [React Context](#react-context) - [Fleet API calls](#fleet-api-calls) - [Page routing](#page-routing) +- [Command palette](#command-palette) - [Styles](#styles) - [Icons and images](#icons-and-images) - [Testing](#testing) @@ -659,6 +660,174 @@ const PageOrComponent = ({ }; ``` +## Command palette + +The command palette is the keyboard surface for navigation and global actions. Power users discover features through it, so a missing entry is easy for them to overlook — but only the right kinds of features belong here. + +Source: `frontend/components/CommandPalette/`. Items are defined per group under `groups/`. + +### What belongs (and what doesn't) + +**Belongs in the palette:** + +- Navigation to any destination in the app — either its own top-level palette entry or nested under a parent entry via `subItems` +- Global create actions where no entity is pre-selected ("Add report" opens a blank form) +- Singleton config actions where the entity is implicit ("Edit Apple MDM" — there's only one Apple MDM config) +- Picker actions that open an in-palette search for the user to choose an entity (e.g., "View host") + +**Doesn't belong:** + +- Per-entity edit / delete operations (editing a specific label, deleting a specific host) — those live on the entity's row or detail page where the entity is already in scope +- Bulk-select operations that depend on an existing selection on a page +- One-off UI affordances (toggles, expanders) tied to a specific view + +The dividing line: if the action requires the user to first pick a specific row, it stays on that row. If the action is global, a singleton, or starts a picker, it goes in the palette. + +### When to add a palette entry + +| Adding... | Goes in | +|---|---| +| A new top-level page (routed under a top nav item) | `groups/pages.ts` | +| A new global create action (modal / form / blank create page) | `groups/commands.ts` | +| A new picker action (like "View host") | `groups/commands.ts` with `opensPickerPage: true`, plus a picker in `frontend/components/CommandPalette/components/` | +| A new MDM platform or connector (turn-on / singleton-edit) | `groups/mdm.ts` | +| A new automation hook | `groups/automations.ts` | +| A new settings page or admin route | `groups/settings.ts` | +| A new control / policy / script feature | `groups/controls.ts` | +| A new software action or view | `groups/software.ts` | + +Nested destinations under an existing palette entry live in that entry's `subItems` array, not as top-level entries. The user reaches the sub-item by expanding the parent (chevron) or when their search promotes the sub-item into Best match. + +These three "sub-" terms each mean exactly one thing in this codebase — keep them distinct: + +- **Sub-item** — an `ICommandSubItem` in a parent palette entry's `subItems` array +- **Picker page** — the secondary screen opened when an entry has `opensPickerPage: true` (View host, View report, Switch fleet) +- **Sub-route** — an app route nested under another (e.g., `/settings/integrations` under `/settings`) + +### Required and optional fields + +```ts +interface ICommandItem { + id: string; // unique kebab-case + label: string; // sentence case, verb first ("Add report") + group: typeof GROUPS[number]; // one of `GROUPS` in helpers.ts + path?: string; // navigation target (use withTeamId() if team-scoped) + onAction?: () => void; // alternative to path for custom side effects + keywords?: string[]; // synonyms + aliases — see below + teamName?: string; // chip shown when the action switches the user's fleet context + subItems?: ICommandSubItem[]; + opensPickerPage?: boolean; // shows the chevron-right; required for picker actions +} +``` + +### Label conventions + +- Sentence case: "Add report", not "Add Report". +- Verb first for actions: "Add", "Edit", "Delete", "Run", "View", "Manage", "Turn on" / "Turn off". +- No trailing punctuation. +- Match the destination page's own primary-button text where possible. +- Use **fleet** / **report** (current product terminology), not **team** / **query**. Existing items haven't been mass-renamed; this applies to *new* items only. + +### Keyword authoring + +Best match scoring is **label-first by tier.** `scoreMatch()` in `helpers.ts` ranks a single text (label or keyword) against the query and returns one of these tier values: + +| Tier | Label score | Keyword score | +|---|---|---| +| exact | 100 | 50 | +| prefix | 90 | 40 | +| word-prefix | 80 | 30 | +| substring | 70 | — (label-only) | + +Any label hit outranks any keyword hit — even the weakest label tier (substring, 70) beats the strongest keyword tier (exact, 50). That's what shapes how keywords should be written: they only matter when the query doesn't hit the label at all. Duplicating label words in keywords just adds a redundant, lower-scoring path. + +A few additional behaviors worth knowing — see `computeBestMatch()` and `scoreItemForBestMatch()` in `helpers.ts` for the full mechanics: + +- **Noise floor.** 2-character queries only consider label-exact + label-prefix (no word-prefix, no substring, no keywords). 3+ characters unlocks the full ladder. See `BEST_MATCH_MIN_QUERY` / `BEST_MATCH_FULL_LADDER_MIN`. +- **Multi-token search.** A query like "settings org" is also scored as two tokens; each must find a positive match (against label or keywords), and the item takes the *minimum per-token score*. This lets order-independent searches like "settings org" → "Organization settings" promote without a phrase match. +- **Word splits.** Word boundaries split on whitespace AND hyphens, so "API-only user" yields `["api", "only", "user"]` — a query for `only` word-prefix-matches. +- **Substring is label-only.** Keyword substrings don't score (too noisy with short tokens); keywords cap at word-prefix. + +**Do:** + +- Add single distinct words a user might type that aren't already in the label +- Add the standard verb synonyms for every action label: + - `add` → `create`, `new` + - `edit` → `update`, `change`, `modify` + - `delete` → `remove` + - `view` → `open`, `show` + - `run` → `execute` + - `turn on` → `activate`, `set up`, `configure` +- Add acronyms and alternate names users actually type: `idp`, `ca`, `cve`, `fma`, `abm`, `vpp`, `mdm`, `dep`, `ade` +- Add platform aliases where relevant: + - Apple → `iphone`, `ipad`, `macbook` + - Windows → `pc`, `win10`, `win11` + - Android → `phone`, `tablet` +- Include legacy product terms during rename windows (e.g., `queries`, `query` on Reports until the term fully drains) + +**Don't:** + +- Repeat words from the label. `Add user` already scores "add" or "user" via the label tiers (exact / prefix / word-prefix / substring, 70–100). Adding them as keywords would only score lower (30–50), never changing the ranking. +- Use multi-word keyword phrases when a single word works. A keyword like `create` matches as keyword-exact / -prefix / -word-prefix at the token level. A multi-word keyword like `create user` only matches when the whole phrase appears as one token in the query — multi-token splitting won't reach into it. +- Pile in low-signal substrings ("the", "some", generic verbs). + +### Permission gating + +Mirror the destination page's gate exactly. If the page rejects technicians, gate the palette item on `!isTechnician`. If the destination renders `` on free tier, gate the palette item on `isPremiumTier`. The palette must not route users to a screen they can't use. + +Reuse existing flags from `ICommandPaletteContext` (`frontend/components/CommandPalette/helpers.ts`) — that interface is the source of truth for the full list. The flags fall into a few buckets: + +- **Role-based write gates:** `canWrite`, `canAccessSettings`, `canAccessControls`, `canRunLiveReport`, `canAddSoftware`, `canEditCustomVariable`, `canManagePolicyAutomations`, `canManageSoftwareAutomations`, `canManageReportAutomations`, `isTechnician` +- **Tier / mode:** `isPremiumTier`, `isPrimoMode`, `isDarkMode` +- **Feature configured:** `isMacMdmEnabledAndConfigured`, `isWindowsMdmEnabledAndConfigured`, `isAndroidMdmEnabledAndConfigured`, `isVppEnabled` +- **Context shape:** `hasTeamSelected`, `currentTeam`, `availableTeams`, `config`, `search` + +Add a new flag to `ICommandPaletteContext` + `CommandPalette.tsx` only when no existing one models the destination's check. When you add one, mirror the destination page's predicate exactly — several existing flags (`canManageReportAutomations`, `canEditCustomVariable`, `canAddSoftware`) document the narrower role checks they encode; follow that pattern. + +### Team context (`teamName`) + +Set `teamName` when invoking the action will switch the user's current fleet context. The palette renders it as a chip on the right so the user sees the upcoming switch before they click. + +Each group builder receives an `IDerivedContext` (computed once by `deriveContext()` in `groups/derivations.ts`) as its second argument. Destructure the chip helper you need from it rather than hardcoding fleet names: + +```ts +const buildExampleItems = (ctx, derived) => { + const { switchesFromUnassigned, switchesFromAllFleets } = derived; + // ... +}; +``` + +The three chip helpers: + +- `switchesFromUnassigned` — destination requires a specific fleet, action invokable from Unassigned +- `switchesFromAllFleets` — destination requires a specific fleet, action invokable from All fleets +- `defaultDestination` — destination always lands on the default (e.g., "All fleets") + +Each returns `undefined` when no switch will actually happen, so you can pass it straight to `teamName` without guarding. + +### Search-only items + +Some entries are gated on the search string itself (e.g., the "Packs" page only appears when searching for `packs`). Use the `search` field from `ICommandPaletteContext` and a regex test: + +```ts +.../packs|create new pack/.test(search.toLowerCase()) + ? [/* the item */] + : [] +``` + +Use this pattern sparingly — it bypasses the normal Best match ranking and should be reserved for legacy / deprecated features users only reach by name. + +### Tests + +Extend `frontend/components/CommandPalette/helpers.tests.ts` when adding a meaningful item: + +- New page / command: assert it appears for the right roles, hides for the wrong ones +- Premium-only: assert it's absent in the `Fleet Free (isPremiumTier: false)` describe block +- Primo mode hidden: add to the `Primo Mode (isPrimoMode: true)` block +- New `teamName` chip: assert it renders / doesn't render against the relevant fleet contexts + +The scoring helpers (`scoreMatch`, `scoreItemForBestMatch`, `computeBestMatch`, `highlightMatches`) and tier constants (`SCORE_LABEL_*`, `SCORE_KEYWORD_*`) have their own describe blocks in `helpers.tests.ts` — you don't need to re-test the framework when adding an item. If your new item exposes a specific ranking case worth pinning (e.g., a multi-token query that should promote it over a similarly-named item), add a small `computeBestMatch` test alongside. + ## Styles Below are a few need-to-knows about what's available in Fleet's CSS: