FE: Add claude rules and FE patterns around command palette (#46826)
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
@@ -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(<CommandPalette />);
|
||||
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(<CommandPalette />);
|
||||
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(<CommandPalette />);
|
||||
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(<CommandPalette />);
|
||||
await openPalette(user);
|
||||
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
|
||||
|
||||
@@ -222,7 +222,7 @@ const CommandPalette = (): JSX.Element | null => {
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||
|
||||
const subPagePlaceholders: Partial<Record<Page, string>> = {
|
||||
const pickerPagePlaceholders: Partial<Record<Page, string>> = {
|
||||
"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 => {
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{item.opensSubPage && (
|
||||
{item.opensPickerPage && (
|
||||
<span aria-hidden className={`${baseClass}__item-more`}>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
@@ -615,11 +615,11 @@ const CommandPalette = (): JSX.Element | null => {
|
||||
<span className={`${baseClass}__item-label`}>
|
||||
<HighlightedLabel text={target.label} query={search} />
|
||||
</span>
|
||||
{/* 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 && (
|
||||
<span aria-hidden className={`${baseClass}__item-more`}>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
@@ -778,7 +778,9 @@ const CommandPalette = (): JSX.Element | null => {
|
||||
<Command.Input
|
||||
ref={inputRef}
|
||||
className={`${baseClass}__input`}
|
||||
placeholder={subPagePlaceholder ?? "Search for a page or command..."}
|
||||
placeholder={
|
||||
pickerPagePlaceholder ?? "Search for a page or command..."
|
||||
}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -818,15 +820,17 @@ const CommandPalette = (): JSX.Element | null => {
|
||||
)}
|
||||
{page !== "root" && <kbd className={`${baseClass}__esc-hint`}>ESC</kbd>}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{page === "root" ? "" : subPagePlaceholder?.replace(/\.{3}$/, "") ?? ""}
|
||||
{page === "root"
|
||||
? ""
|
||||
: pickerPagePlaceholder?.replace(/\.{3}$/, "") ?? ""}
|
||||
</div>
|
||||
<Command.List className={`${baseClass}__list`}>
|
||||
{/* 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" && (
|
||||
<Command.Empty className={`${baseClass}__empty`}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 `<PremiumFeatureMessage />` 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:
|
||||
|
||||
Reference in New Issue
Block a user