Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/command-palette/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This skill exists to make sure that guide gets followed when palette-worthy chan
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.
4. **Premium paywall check:** if the destination page (or the specific tab/section the link lands on) renders `<PremiumFeatureMessage />` for `!isPremiumTier`, gate the palette item on `isPremiumTier` so it's hidden on Free. A palette entry that lands on the upsell wall is a bait-and-switch — the palette is for actions, not for marketing the paid tier. Mirror tab-level paywalls too (e.g., `paths.ADMIN_INTEGRATIONS_SSO_END_USERS` lands on a Premium-only tab even though the parent SSO page works on Free). If the destination only paywalls a sub-section (not the whole page or the linked tab), don't gate — the page is still useful on Free.

## After adding an item

Expand Down
7 changes: 5 additions & 2 deletions frontend/components/CommandPalette/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import SoftwarePicker from "./components/SoftwarePicker";
import ReportPicker from "./components/ReportPicker";
import PolicyPicker from "./components/PolicyPicker";
import HighlightedLabel from "./components/HighlightedLabel";
import UprightEmoji from "./components/UprightEmoji";
import { isPreFilteredResult } from "./components/constants";

const baseClass = "command-palette";
Expand Down Expand Up @@ -546,7 +547,9 @@ const CommandPalette = (): JSX.Element | null => {
)}
</div>
{item.teamName && (
<span className={`${baseClass}__item-fleet`}>{item.teamName}</span>
<span className={`${baseClass}__item-fleet`}>
<UprightEmoji text={item.teamName} />
</span>
)}
</Command.Item>
{/* Render sub-items when expanded (browsing) or always when searching */}
Expand Down Expand Up @@ -634,7 +637,7 @@ const CommandPalette = (): JSX.Element | null => {
fleets shows "All fleets"). */}
{!sub && item.teamName && (
<span className={`${baseClass}__item-fleet`}>
{item.teamName}
<UprightEmoji text={item.teamName} />
</span>
)}
{/* Parent label as a context chip on promoted sub-items
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import hostsAPI, { ILoadHostsResponse } from "services/entities/hosts";
import usePickerSearch from "./usePickerSearch";
import { RESULT_PREFIXES } from "./constants";
import HighlightedLabel from "./HighlightedLabel";
import UprightEmoji from "./UprightEmoji";

const baseClass = "command-palette";

Expand Down Expand Up @@ -85,7 +86,7 @@ const HostPicker = ({
</span>
{showTeamColumn && (
<span className={`${baseClass}__host-team`}>
{host.team_name || "Unassigned"}
<UprightEmoji text={host.team_name || "Unassigned"} />
</span>
)}
</Command.Item>
Expand Down
109 changes: 109 additions & 0 deletions frontend/components/CommandPalette/components/UprightEmoji.tests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React from "react";
import { render } from "@testing-library/react";

import UprightEmoji, { splitEmojiSegments } from "./UprightEmoji";

describe("splitEmojiSegments", () => {
it("returns a single non-emoji segment for an emoji-free string", () => {
expect(splitEmojiSegments("Workstations")).toEqual([
{ text: "Workstations", isEmoji: false },
]);
});

it("returns an empty segment for an empty string", () => {
expect(splitEmojiSegments("")).toEqual([{ text: "", isEmoji: false }]);
});

it("splits an emoji prefix from the trailing text", () => {
expect(splitEmojiSegments("💻 Workstations")).toEqual([
{ text: "💻", isEmoji: true },
{ text: " Workstations", isEmoji: false },
]);
});

it("coalesces adjacent emoji into one segment", () => {
expect(splitEmojiSegments("📱🔐 Personal mobile devices")).toEqual([
{ text: "📱🔐", isEmoji: true },
{ text: " Personal mobile devices", isEmoji: false },
]);
});

it("handles emoji inside the string (not just at the start)", () => {
expect(splitEmojiSegments("Team 🚀 alpha")).toEqual([
{ text: "Team ", isEmoji: false },
{ text: "🚀", isEmoji: true },
{ text: " alpha", isEmoji: false },
]);
});

it("handles a ZWJ-joined emoji as a single segment", () => {
// Man technologist: 👨 + ZWJ + 💻 → one grapheme
expect(splitEmojiSegments("👨‍💻 Engineering")).toEqual([
{ text: "👨‍💻", isEmoji: true },
{ text: " Engineering", isEmoji: false },
]);
});

it("handles emoji modifiers (skin tone) without splitting the grapheme", () => {
expect(splitEmojiSegments("👍🏽 Team")).toEqual([
{ text: "👍🏽", isEmoji: true },
{ text: " Team", isEmoji: false },
]);
});

it("handles modifier + ZWJ sequences as a single segment", () => {
// Man technologist with medium skin tone: 👨 + 🏽 + ZWJ + 💻
expect(splitEmojiSegments("👨🏽‍💻 Engineering")).toEqual([
{ text: "👨🏽‍💻", isEmoji: true },
{ text: " Engineering", isEmoji: false },
]);
});

it("handles a regional indicator pair (flag emoji) as one segment", () => {
// 🇺🇸 = U+1F1FA U+1F1F8
expect(splitEmojiSegments("\u{1F1FA}\u{1F1F8} US fleet")).toEqual([
{ text: "\u{1F1FA}\u{1F1F8}", isEmoji: true },
{ text: " US fleet", isEmoji: false },
]);
});
Comment thread
Copilot marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("handles emoji at the very end of the string", () => {
expect(splitEmojiSegments("Sales team 🏆")).toEqual([
{ text: "Sales team ", isEmoji: false },
{ text: "🏆", isEmoji: true },
]);
});
});

describe("UprightEmoji", () => {
it("renders plain text when there are no emoji", () => {
const { container } = render(<UprightEmoji text="Workstations" />);
expect(container.textContent).toBe("Workstations");
expect(container.querySelectorAll("span")).toHaveLength(0);
});

it("wraps emoji segments in a span with font-style: normal", () => {
const { container } = render(<UprightEmoji text="💻 Workstations" />);
const spans = container.querySelectorAll("span");
expect(spans).toHaveLength(1);
expect(spans[0].textContent).toBe("💻");
expect(spans[0].style.fontStyle).toBe("normal");
expect(container.textContent).toBe("💻 Workstations");
});

it("coalesces adjacent emoji into a single span", () => {
const { container } = render(
<UprightEmoji text="📱🔐 Personal mobile devices" />
);
const spans = container.querySelectorAll("span");
expect(spans).toHaveLength(1);
expect(spans[0].textContent).toBe("📱🔐");
});

it("preserves a ZWJ sequence as one upright span", () => {
const { container } = render(<UprightEmoji text="👨‍💻 Engineering" />);
const spans = container.querySelectorAll("span");
expect(spans).toHaveLength(1);
expect(spans[0].textContent).toBe("👨‍💻");
});
});
109 changes: 109 additions & 0 deletions frontend/components/CommandPalette/components/UprightEmoji.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React from "react";

// Matches an emoji "grapheme" — a single emoji codepoint with optional
// variation selector / ZWJ continuations, OR a regional indicator pair
// (used for flag emoji like 🇺🇸). Browsers fake italic by shearing every
// glyph including emoji, so wrapping emoji in `font-style: normal` opts
// them out of the synthetic slant while leaving Latin text italic via
// the parent's `font-style: italic`.
// \uFE0F = variation selector-16 (force emoji presentation)
// \u200D = zero-width joiner (combines emoji into one grapheme,
// e.g. 👨‍💻 → "man technologist")
const EMOJI_RUN_RE = /(?:\p{Extended_Pictographic}(?:\p{Emoji_Modifier})?\uFE0F?(?:\u200D\p{Extended_Pictographic}(?:\p{Emoji_Modifier})?\uFE0F?)*|\p{Regional_Indicator}\p{Regional_Indicator})/gu;

export interface IEmojiSegment {
text: string;
isEmoji: boolean;
}

/**
* Split `text` into alternating emoji vs non-emoji segments. Pure helper
* exposed for testing; production callers use the <UprightEmoji /> wrapper.
*
* Returns a single non-emoji segment when no emoji are present, so callers
* can fast-path the bare-string case without diffing array shapes.
*/
export const splitEmojiSegments = (text: string): IEmojiSegment[] => {
if (!text) return [{ text: "", isEmoji: false }];
// Collect match positions via String.replace with a callback. We don't
// actually replace anything — replace just exposes (match, offset) for
// each hit. Using replace instead of String.matchAll keeps the helper
// compatible with pre-ES2020 lib targets and sidesteps mutating the
// global regex's lastIndex.
const matches: Array<{ start: number; end: number }> = [];
text.replace(EMOJI_RUN_RE, (match, offset) => {
const start = offset as number;
matches.push({ start, end: start + match.length });
return match;
});

const segments: IEmojiSegment[] = [];
let cursor = 0;
// Coalesce adjacent emoji matches (e.g. "📱🔐") into one segment so the
// DOM stays compact and the span attribute count doesn't balloon for
// rows that prefix two or three emoji.
let pendingEmojiStart = -1;
let pendingEmojiEnd = -1;
const flushEmoji = () => {
if (pendingEmojiStart === -1) return;
segments.push({
text: text.slice(pendingEmojiStart, pendingEmojiEnd),
isEmoji: true,
});
pendingEmojiStart = -1;
pendingEmojiEnd = -1;
};
matches.forEach(({ start, end }) => {
if (start === pendingEmojiEnd) {
pendingEmojiEnd = end;
cursor = end;
return;
}
flushEmoji();
if (start > cursor) {
segments.push({ text: text.slice(cursor, start), isEmoji: false });
}
pendingEmojiStart = start;
pendingEmojiEnd = end;
cursor = end;
});
flushEmoji();
if (cursor < text.length) {
segments.push({ text: text.slice(cursor), isEmoji: false });
}
if (segments.length === 0) return [{ text, isEmoji: false }];
return segments;
};

interface IUprightEmojiProps {
text: string;
}

/**
* Renders `text` so emoji glyphs stay upright even when the surrounding
* element has `font-style: italic`. Use anywhere fleet names (or other
* user-provided strings that may carry emoji) appear inside italicized
* surfaces — synthetic italic shears emoji and looks broken.
*/
const UprightEmoji = ({ text }: IUprightEmojiProps): JSX.Element => {
const segments = splitEmojiSegments(text);
return (
<>
{segments.map((seg, i) =>
seg.isEmoji ? (
// Index keys are safe — segments derive synchronously from the
// same input each render, so order is stable.
// eslint-disable-next-line react/no-array-index-key
<span key={i} style={{ fontStyle: "normal" }}>
{seg.text}
</span>
) : (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={i}>{seg.text}</React.Fragment>
)
)}
</>
);
};

export default UprightEmoji;
38 changes: 22 additions & 16 deletions frontend/components/CommandPalette/groups/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,28 @@ const buildControlsItems = (
if (!canAccessControls || !hasTeamOrUnassigned) return [];

return [
{
id: "controls-os-updates",
label: "OS updates",
group: "Controls" as const,
path: withTeamId(paths.CONTROLS_OS_UPDATES),
keywords: [
"minimum version",
"deadline",
"nudge",
"macos",
"windows",
"ios",
"ipados",
"patch",
],
},
// OS updates is Premium-only — OSUpdates renders <PremiumFeatureMessage />
// on Free.
...(isPremiumTier
? [
{
id: "controls-os-updates",
label: "OS updates",
group: "Controls" as const,
path: withTeamId(paths.CONTROLS_OS_UPDATES),
keywords: [
"minimum version",
"deadline",
"nudge",
"macos",
"windows",
"ios",
"ipados",
"patch",
],
},
]
: []),
// OS settings sub-routes
{
id: "controls-os-settings",
Expand Down
10 changes: 8 additions & 2 deletions frontend/components/CommandPalette/groups/derivations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ICommandPaletteContext } from "../helpers";
* to each builder so the derivation logic lives in exactly one place.
*/
export interface IDerivedContext {
/** Apple Business Manager configured at the org level. */
/** Apple Business configured at the org level. */
isAbmConfigured: boolean;
/** GitOps mode active — disables Create-fleet etc. */
isGitOpsMode: boolean;
Expand Down Expand Up @@ -36,6 +36,7 @@ export const deriveContext = (ctx: ICommandPaletteContext): IDerivedContext => {
availableTeams,
hasTeamSelected,
isPrimoMode,
isPremiumTier,
} = ctx;

const isAbmConfigured = config?.mdm?.apple_bm_enabled_and_configured ?? false;
Expand All @@ -47,7 +48,12 @@ export const deriveContext = (ctx: ICommandPaletteContext): IDerivedContext => {
);

const isUnassigned = currentTeam?.id === 0;
const hasTeamOrUnassigned = !!hasTeamSelected || isUnassigned;
// On Fleet Free, the team concept doesn't apply — there's a single
// implicit fleet, no team picker, and currentTeam stays undefined. Treat
// Free as team-or-unassigned so team-gated palette items (Controls, Add
// script, etc.) still surface on Free-available destinations.
const hasTeamOrUnassigned =
!isPremiumTier || !!hasTeamSelected || isUnassigned;

// In Primo Mode the user perceives a single-fleet install, so the
// concept of "switching fleet context" doesn't apply. All destination
Expand Down
6 changes: 3 additions & 3 deletions frontend/components/CommandPalette/groups/mdm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ const buildMdmItems = (
"macbook",
],
},
// ABM and VPP pages are Premium-only.
// AB and VPP pages are Premium-only.
...(isPremiumTier
? [
{
id: isAbmConfigured ? "edit-abm" : "add-abm",
label: isAbmConfigured
? "Edit Apple Business Manager (ABM)"
: "Add Apple Business Manager (ABM)",
? "Edit Apple Business (AB)"
: "Add Apple Business (AB)",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_APPLE_BUSINESS_MANAGER,
keywords: [
Expand Down
14 changes: 12 additions & 2 deletions frontend/components/CommandPalette/groups/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ const buildPagesItems = (
ctx: ICommandPaletteContext,
derived: IDerivedContext
): ICommandItem[] => {
const { search, canAccessControls, canAccessSettings, withTeamId } = ctx;
const {
search,
canAccessControls,
canAccessSettings,
isPremiumTier,
withTeamId,
} = ctx;
const {
hasTeamOrUnassigned,
switchesFromUnassigned,
Expand Down Expand Up @@ -45,7 +51,11 @@ const buildPagesItems = (
"computers",
],
},
...(canAccessControls && hasTeamOrUnassigned
// Hidden on Free: /controls redirects to OS updates, which renders
// <PremiumFeatureMessage /> on Free. Free users still reach the
// tier-free Controls sub-pages via their own palette entries
// (OS settings, Scripts, Variables).
...(canAccessControls && hasTeamOrUnassigned && isPremiumTier
? [
{
id: "controls-page",
Expand Down
Loading
Loading