Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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 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`.
// ️ = variation selector-16 (force emoji presentation)
// ‍ = zero-width joiner (combines emoji into one grapheme,
// e.g. 👨‍💻 → "man technologist")
const EMOJI_RUN_RE = /(?:\p{Extended_Pictographic}️?(?:‍\p{Extended_Pictographic}️?)*|\p{Regional_Indicator}\p{Regional_Indicator})/gu;
Comment thread
Copilot marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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