Fleet UI: "Fleet spotlight" (Command palette menu) (#43756)

This commit is contained in:
RachelElysia
2026-05-28 09:25:54 -04:00
committed by GitHub
parent 1fd32bef63
commit 7c3355295a
44 changed files with 5663 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
* Fleet UI: Introducing Fleet "Spotlight" - A command palette that opens when pressing Command + K or Control + K
@@ -0,0 +1,412 @@
import React from "react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import createMockConfig from "__mocks__/configMock";
import CommandPalette from "./CommandPalette";
// cmdk uses scrollIntoView which JSDOM doesn't implement
Element.prototype.scrollIntoView = jest.fn();
const adminRender = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: {
id: 1,
name: "Test User",
email: "test@fleet.co",
global_role: "admin",
},
config: createMockConfig(),
isGlobalAdmin: true,
isGlobalMaintainer: false,
isAnyTeamAdmin: false,
isAnyTeamMaintainer: false,
isGlobalTechnician: false,
isAnyTeamTechnician: false,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
isWindowsMdmEnabledAndConfigured: true,
isAndroidMdmEnabledAndConfigured: false,
isNoAccess: false,
isOnlyObserver: false,
availableTeams: [
{ id: -1, name: "All fleets" },
{ id: 1, name: "Engineering" },
{ id: 2, name: "Sales" },
],
currentTeam: { id: 1, name: "Engineering" },
},
},
});
const observerRender = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: {
id: 2,
name: "Observer",
email: "observer@fleet.co",
global_role: "observer",
},
config: createMockConfig(),
isGlobalAdmin: false,
isGlobalMaintainer: false,
isAnyTeamAdmin: false,
isAnyTeamMaintainer: false,
isGlobalTechnician: false,
isAnyTeamTechnician: false,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
isWindowsMdmEnabledAndConfigured: true,
isAndroidMdmEnabledAndConfigured: false,
isNoAccess: false,
isOnlyObserver: true,
availableTeams: [
{ id: -1, name: "All fleets" },
{ id: 1, name: "Engineering" },
],
currentTeam: { id: 1, name: "Engineering" },
},
},
});
const openPalette = async (user: ReturnType<typeof adminRender>["user"]) => {
await user.keyboard("{Meta>}k{/Meta}");
await waitFor(() => {
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
};
describe("CommandPalette", () => {
describe("Opening and closing", () => {
it("renders nothing when closed", () => {
adminRender(<CommandPalette />);
expect(
screen.queryByLabelText("Command palette")
).not.toBeInTheDocument();
});
it("opens on Cmd+K", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
it("closes on Escape", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.queryByPlaceholderText(/search/i)
).not.toBeInTheDocument();
});
});
it("closes when Cmd+K is pressed again", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Meta>}k{/Meta}");
await waitFor(() => {
expect(
screen.queryByPlaceholderText(/search/i)
).not.toBeInTheDocument();
});
});
it("resets search when reopened", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Type something then close
await user.keyboard("dashboard");
await user.keyboard("{Escape}");
// Reopen — input should be empty
await openPalette(user);
expect(screen.getByPlaceholderText(/search/i)).toHaveValue("");
});
});
describe("Rendering items", () => {
it("shows page items when open", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Hosts")).toBeInTheDocument();
expect(screen.getByText("Policies")).toBeInTheDocument();
});
it("shows group headings", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
expect(screen.getByText("Pages")).toBeInTheDocument();
expect(screen.getByText("Commands")).toBeInTheDocument();
});
it("shows team name on team-scoped actions", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Multiple items should show the team name
const teamLabels = screen.getAllByText("Engineering");
expect(teamLabels.length).toBeGreaterThan(0);
});
// cmdk only renders Command.Empty when zero items match — can't trigger
// in JSDOM since cmdk filtering doesn't respond to DOM events.
it.todo("shows 'No results found.' for unmatched search");
});
describe("Fleet switcher header", () => {
it("shows the current fleet on the header switcher button", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
const switcher = screen.getByRole("button", { name: /Engineering/ });
expect(switcher).toBeInTheDocument();
});
it("navigates to the switch-fleet page when the header button is clicked", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
const switcher = screen.getByRole("button", { name: /Engineering/ });
await user.click(switcher);
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
});
describe("Keyboard shortcuts", () => {
it("opens the switch-fleet sub-page on Cmd+Shift+F", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
});
it("Cmd+Shift+F also opens the palette directly to switch-fleet from closed", async () => {
const { user } = adminRender(<CommandPalette />);
// Don't openPalette first — verify cold-start behavior.
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
});
it("Escape returns to root from a sub-page instead of closing", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Navigate into the switch-fleet sub-page
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
// ESC should take us back to root, not close the dialog
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search for a page or command...")
).toBeInTheDocument();
});
});
it("Escape returns to root from a picker sub-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.
const viewHost = await screen.findByText("View host");
fireEvent.click(viewHost);
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search hosts...")
).toBeInTheDocument();
});
// ESC takes us back to root, NOT closing the dialog.
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search for a page or command...")
).toBeInTheDocument();
});
});
it("Escape closes the palette when on the root page", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.queryByPlaceholderText(/search/i)
).not.toBeInTheDocument();
});
});
it("Backspace on empty input goes back from a sub-page", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
// Backspace with empty input → root page
await user.keyboard("{Backspace}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search for a page or command...")
).toBeInTheDocument();
});
});
});
describe("Sign out", () => {
it("renders Sign out under the Commands group", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
expect(screen.getByText("Sign out")).toBeInTheDocument();
});
});
describe("Dark mode reactivity", () => {
it("updates the toggle-dark-mode label on fleet-theme-change events", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Initial render — theme defaults to light in tests.
expect(screen.getByText("Switch to dark mode")).toBeInTheDocument();
// Simulate the theme flipping to dark from elsewhere (system theme,
// another tab, sibling component).
window.dispatchEvent(
new CustomEvent("fleet-theme-change", { detail: { dark: true } })
);
await waitFor(() => {
expect(screen.getByText("Switch to light mode")).toBeInTheDocument();
});
});
});
describe("Sub-items", () => {
it("shows chevron on items with sub-items", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// OS settings has sub-items
const osSettingsItem = screen
.getByText("OS settings")
.closest(`.command-palette__item`);
expect(
osSettingsItem?.querySelector(`.command-palette__item-more`)
).toBeInTheDocument();
});
it("expands sub-items on chevron click", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Sub-items should not be visible initially
expect(screen.queryByText("Disk encryption")).not.toBeInTheDocument();
// Click the chevron on OS settings — use fireEvent since cmdk
// intercepts user.click and navigates instead of toggling
const chevron = screen
.getByText("OS settings")
.closest(`.command-palette__item`)
?.querySelector(`.command-palette__item-more`);
expect(chevron).toBeInTheDocument();
fireEvent.click(chevron!);
await waitFor(() => {
expect(screen.getByText("Disk encryption")).toBeInTheDocument();
});
});
});
describe("Permission gating", () => {
it("renders nothing for isNoAccess users", () => {
const noAccessRender = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isNoAccess: true,
currentUser: {
id: 1,
name: "No Access",
email: "noaccess@fleet.co",
global_role: null,
},
config: createMockConfig(),
},
},
});
noAccessRender(<CommandPalette />);
expect(
screen.queryByLabelText("Command palette")
).not.toBeInTheDocument();
});
it("hides Actions and Controls for observers", async () => {
const { user } = observerRender(<CommandPalette />);
await openPalette(user);
// Pages should still be visible
expect(screen.getByText("Dashboard")).toBeInTheDocument();
// Actions and Controls should not appear
expect(screen.queryByText("Add hosts")).not.toBeInTheDocument();
expect(screen.queryByText("Add report")).not.toBeInTheDocument();
expect(screen.queryByText("OS updates")).not.toBeInTheDocument();
});
it("hides Settings group for non-admins", async () => {
const { user } = observerRender(<CommandPalette />);
await openPalette(user);
expect(
screen.queryByText("Organization settings")
).not.toBeInTheDocument();
expect(screen.queryByText("Integrations")).not.toBeInTheDocument();
});
});
// cmdk manages its own internal filtering state and doesn't respond to
// DOM events in JSDOM. Filtering logic is covered in helpers.tests.ts.
it.todo("filters items based on search input");
});
@@ -0,0 +1,827 @@
import React, {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
useCallback,
useRef,
} from "react";
import { Command } from "cmdk";
import { browserHistory } from "react-router";
import { AppContext } from "context/app";
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import Icon from "components/Icon";
import { isDarkMode, setThemeMode } from "utilities/theme";
import paths from "router/paths";
import {
ICommandItem,
ICommandSubItem,
GROUPS,
buildPaletteItems,
} from "./helpers";
import FleetPicker from "./components/FleetPicker";
import HostPicker from "./components/HostPicker";
import SoftwarePicker from "./components/SoftwarePicker";
import ReportPicker from "./components/ReportPicker";
import PolicyPicker from "./components/PolicyPicker";
import { isPreFilteredResult } from "./components/constants";
const baseClass = "command-palette";
// Pure helper hoisted to module scope so it's stable across renders and
// can be safely called from inside memoization.
const getItemValue = (item: ICommandItem) => {
const parts = [item.label, ...(item.keywords ?? [])];
item.subItems?.forEach((sub) => {
parts.push(sub.label, ...(sub.keywords ?? []));
});
return parts.join(" ");
};
type Page =
| "root"
| "switch-fleet"
| "view-host"
| "view-software"
| "view-software-library"
| "view-report"
| "view-policy";
const CommandPalette = (): JSX.Element | null => {
const [open, setOpen] = useState(false);
const [page, setPage] = useState<Page>("root");
const [search, setSearch] = useState("");
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const {
availableTeams,
currentTeam,
setCurrentTeam,
isGlobalAdmin,
isGlobalMaintainer,
isAnyTeamAdmin,
isAnyTeamMaintainer,
isTeamAdmin,
isTeamMaintainer,
isGlobalTechnician,
isAnyTeamTechnician,
isObserverPlus,
isAnyTeamObserverPlus,
isGlobalObserver,
isTeamObserver,
isPremiumTier,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isAndroidMdmEnabledAndConfigured,
isVppEnabled,
config,
isNoAccess,
} = useContext(AppContext);
const isTechnician = isGlobalTechnician || isAnyTeamTechnician;
const canAccessControls =
isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamAdmin ||
isAnyTeamMaintainer ||
isTechnician;
const canWrite =
isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamAdmin ||
isAnyTeamMaintainer ||
isTechnician;
// Custom variables are admin-tier global config (mirrors Variables.tsx
// `canEdit`). Team admins/maintainers/technicians lack the role even
// though they have `canWrite`, so the destination page would render
// a read-only view — gate the palette entry accordingly.
const canEditCustomVariable = !!isGlobalAdmin || !!isGlobalMaintainer;
// Mirrors SoftwarePage.tsx canAddSoftware. Note isTeamAdmin /
// isTeamMaintainer here are scoped to currentTeam by AppContext — a
// user who is admin of Team A but observer of Team B (currently
// selected) correctly evaluates to false. canWrite would have
// accepted them via isAnyTeamAdmin.
const canAddSoftware =
!!isGlobalAdmin ||
!!isGlobalMaintainer ||
!!isTeamAdmin ||
!!isTeamMaintainer;
// Observer+ users can run live queries even though they can't write.
const canRunLiveReport =
canWrite || !!isObserverPlus || !!isAnyTeamObserverPlus;
// Used by ReportPicker to decide whether to render the "Observers can
// run" affordance on a report — that hint is meant for non-observers
// (it advertises which reports they can hand off), so suppress for
// observers viewing their own scope.
const isViewerObserverInScope = !!isGlobalObserver || !!isTeamObserver;
// Primo Mode is a single-fleet premium installation. The fleet switcher
// should be hidden, fleet creation disabled, and All-fleets-only commands
// need to surface for the user's single fleet.
const isPrimoMode = !!config?.partnerships?.enable_primo;
// Track theme as reactive state so the toggle-dark-mode item's label
// updates if the theme flips externally (system theme media query,
// another tab, sibling component). utilities/theme dispatches a
// `fleet-theme-change` window event on every change.
const [isDarkModeActive, setIsDarkModeActive] = useState(isDarkMode);
useEffect(() => {
const onThemeChange = (e: Event) => {
const detail = (e as CustomEvent<{ dark: boolean }>).detail;
setIsDarkModeActive(!!detail?.dark);
};
window.addEventListener("fleet-theme-change", onThemeChange);
return () =>
window.removeEventListener("fleet-theme-change", onThemeChange);
}, []);
// Policy automations: same as canAddOrDeletePolicies in ManagePoliciesPage
const canManagePolicyAutomations =
isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamAdmin ||
isAnyTeamMaintainer;
// Software automations require global admin (all fleets view)
const canManageSoftwareAutomations = isGlobalAdmin;
const canAccessSettings = isGlobalAdmin;
// Whether a specific team is selected (not "All teams")
const hasTeamSelected = currentTeam && currentTeam.id > 0;
const isUnassigned = currentTeam?.id === 0;
// Append fleet_id to a path so navigation preserves the current team context.
// Includes Unassigned (id 0) so navigation doesn't drop the no-team context.
const withTeamId = useCallback(
(path: string) => {
if (!hasTeamSelected && !isUnassigned) {
return path;
}
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}fleet_id=${currentTeam?.id}`;
},
[hasTeamSelected, isUnassigned, currentTeam?.id]
);
// Reset page and search when dialog opens/closes
useEffect(() => {
if (!open) {
setPage("root");
setSearch("");
setExpandedItems(new Set());
}
}, [open]);
const canSwitchFleet =
isPremiumTier &&
!isPrimoMode &&
!!availableTeams &&
availableTeams.length > 1;
// Display label for the fleet switcher button — falls back to the
// "All fleets" sentinel when no specific team is selected. Used in
// both the visible button text and the aria-label.
const fleetSwitcherLabel = currentTeam?.name || "All fleets";
// Detect macOS so we can render the Cmd glyph (⌘) vs. "Ctrl" inline on
// the fleet-switcher shortcut. navigator.platform is deprecated but
// still the most reliable cross-browser signal for this binary check.
const isMacPlatform =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
const subPagePlaceholders: Partial<Record<Page, string>> = {
"switch-fleet": "Search a fleet...",
"view-host": "Search hosts...",
"view-software": "Search software inventory...",
"view-software-library": "Search software library...",
"view-report": "Search reports...",
"view-policy": "Search policies...",
};
const subPagePlaceholder = subPagePlaceholders[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
// input ref isn't set until Radix's portal mounts.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
} else if (
e.shiftKey &&
(e.key === "f" || e.key === "F") &&
canSwitchFleet
) {
e.preventDefault();
setOpen(true);
setSearch("");
setPage("switch-fleet");
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canSwitchFleet]);
const navigate = useCallback((path: string) => {
setOpen(false);
browserHistory.push(path);
}, []);
const goToPage = useCallback((newPage: Page) => {
setSearch("");
setPage(newPage);
}, []);
const goBack = useCallback(() => {
setSearch("");
setPage("root");
}, []);
// Focus the input whenever the dialog is open or the page changes. Runs
// after the portal has mounted the input, unlike rAF in event handlers
// (which fires before Radix's first commit when opening from closed).
useEffect(() => {
if (open) {
inputRef.current?.focus();
}
}, [open, page]);
// Backspace on empty input returns to root from a sub-page.
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (page !== "root" && e.key === "Backspace" && !search) {
e.preventDefault();
goBack();
}
},
[page, search, goBack]
);
// Intercept Escape on a sub-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
// 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
// before any `useEffect` in deeper Radix components, guaranteeing
// priority in the capture phase regardless of mount order. Click-
// outside still closes the palette outright via onOpenChange.
const pageRef = useRef(page);
pageRef.current = page;
useLayoutEffect(() => {
if (!open) {
return undefined;
}
const onDocKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && pageRef.current !== "root") {
e.preventDefault();
e.stopImmediatePropagation();
goBack();
}
};
document.addEventListener("keydown", onDocKey, true);
return () => document.removeEventListener("keydown", onDocKey, true);
}, [open, goBack]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen);
}, []);
const handleSwitchFleet = useCallback(
(fleetId: number) => {
const selected = availableTeams?.find((t) => t.id === fleetId);
if (selected) {
setCurrentTeam(selected);
}
const { pathname, search: currentSearch } = window.location;
const isAll = fleetId === APP_CONTEXT_ALL_TEAMS_ID;
const isUnassignedTarget = fleetId === 0;
// Pages that require a specific fleet — can't render "All fleets" or
// (with some overlap) "Unassigned". When switching to those contexts
// from one of these pages, fall back to Hosts which supports both.
const teamRequiredPrefixes = [
paths.CONTROLS,
paths.SOFTWARE_LIBRARY,
paths.NEW_REPORT,
];
const isOnTeamRequiredPage = teamRequiredPrefixes.some((p) =>
pathname.startsWith(p)
);
if ((isAll || isUnassignedTarget) && isOnTeamRequiredPage) {
// For Unassigned, keep fleet_id=0 on the fallback URL.
// useTeamIdParam coerces a missing param back to All fleets (-1),
// which would silently undo the setCurrentTeam({id:0}) above.
browserHistory.push(
isUnassignedTarget
? `${paths.MANAGE_HOSTS}?fleet_id=0`
: paths.MANAGE_HOSTS
);
} else {
const params = new URLSearchParams(currentSearch);
if (isAll) {
params.delete("fleet_id");
} else {
params.set("fleet_id", String(fleetId));
}
const qs = params.toString();
browserHistory.push(qs ? `${pathname}?${qs}` : pathname);
}
// Return to root so the palette stays open on the main view.
setSearch("");
setPage("root");
},
[availableTeams, setCurrentTeam]
);
const toggleExpanded = useCallback((id: string) => {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Memoize the item array on the values buildPaletteItems actually
// consumes. Inline onToggle/onView callbacks are intentionally excluded
// from deps — they only call already-stable setters/useCallback'd
// goToPage, so they don't change semantically across renders.
const items = useMemo(
() =>
buildPaletteItems({
search,
currentTeam,
availableTeams,
config,
canAccessControls,
canWrite,
canRunLiveReport,
canAccessSettings,
canManagePolicyAutomations,
canManageSoftwareAutomations,
canEditCustomVariable,
canAddSoftware,
isTechnician,
isPremiumTier,
isPrimoMode,
isDarkMode: isDarkModeActive,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isAndroidMdmEnabledAndConfigured,
isVppEnabled,
hasTeamSelected,
withTeamId,
onToggleDarkMode: () => {
setThemeMode(isDarkModeActive ? "light" : "dark");
setOpen(false);
},
onViewHost: () => goToPage("view-host"),
onViewSoftware: () => goToPage("view-software"),
onViewSoftwareLibrary: () => goToPage("view-software-library"),
onViewReport: () => goToPage("view-report"),
onViewPolicy: () => goToPage("view-policy"),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
search,
currentTeam,
availableTeams,
config,
canAccessControls,
canWrite,
canRunLiveReport,
canAccessSettings,
canManagePolicyAutomations,
canManageSoftwareAutomations,
canEditCustomVariable,
canAddSoftware,
isTechnician,
isPremiumTier,
isPrimoMode,
isDarkModeActive,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isAndroidMdmEnabledAndConfigured,
isVppEnabled,
hasTeamSelected,
withTeamId,
goToPage,
]
);
const groupedItems = useMemo(
() =>
items.reduce<Record<string, ICommandItem[]>>((acc, item) => {
if (!acc[item.group]) {
acc[item.group] = [];
}
acc[item.group].push(item);
return acc;
}, {}),
[items]
);
const isSearching = search.length > 0;
const searchLower = search.toLowerCase().trim();
// Map cmdk values (normalized) to parent item IDs for auto-expand on keyboard nav
const valueToParentId = useMemo(() => {
const map = new Map<string, string>();
items.forEach((item) => {
if (item.subItems?.length) {
map.set(getItemValue(item).toLowerCase().trim(), item.id);
item.subItems.forEach((sub) => {
const subValue = `${sub.label} ${sub.keywords?.join(" ") ?? ""}`
.toLowerCase()
.trim();
map.set(subValue, item.id);
});
}
});
return map;
}, [items]);
// Auto expand/collapse sub-items as the user arrows through items
const handleHighlightChange = useCallback(
(value: string) => {
if (isSearching) return;
const parentId = valueToParentId.get(value);
setExpandedItems(parentId ? new Set([parentId]) : new Set());
},
[valueToParentId, isSearching]
);
// Find exact match — an item or sub-item whose label exactly matches the
// search. Memoized so we don't rebuild the Set on every keystroke once
// items is stable.
const exactMatchIds = useMemo(() => {
if (!isSearching) return new Set<string>();
return new Set(
items.reduce<string[]>((acc, item) => {
if (item.label.toLowerCase() === searchLower) {
acc.push(item.id);
}
item.subItems
?.filter((sub) => sub.label.toLowerCase() === searchLower)
.forEach((sub) => acc.push(sub.id));
return acc;
}, [])
);
}, [items, isSearching, searchLower]);
const renderItem = (item: ICommandItem) => {
const isExpanded = expandedItems.has(item.id);
const hasSubItems = item.subItems && item.subItems.length > 0;
return (
<React.Fragment key={item.id}>
<Command.Item
value={getItemValue(item)}
onSelect={() =>
item.onAction ? item.onAction() : navigate(item.path!)
}
className={`${baseClass}__item`}
>
<div className={`${baseClass}__item-left`}>
<span className={`${baseClass}__item-label`}>{item.label}</span>
{hasSubItems && !isSearching && (
<button
type="button"
tabIndex={-1}
className={`${baseClass}__item-more ${
isExpanded ? `${baseClass}__item-more--expanded` : ""
}`}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(item.id);
}}
onPointerDown={(e) => e.preventDefault()}
>
<Icon
name="chevron-down"
size="small"
color="ui-fleet-black-50"
/>
</button>
)}
{item.opensSubPage && (
<span aria-hidden className={`${baseClass}__item-more`}>
<Icon
name="chevron-right"
size="small"
color="ui-fleet-black-50"
/>
</span>
)}
</div>
{item.teamName && (
<span className={`${baseClass}__item-fleet`}>{item.teamName}</span>
)}
</Command.Item>
{/* Render sub-items when expanded (browsing) or always when searching */}
{hasSubItems &&
(isExpanded || isSearching) &&
item.subItems &&
item.subItems.map((sub) => (
<Command.Item
key={sub.id}
value={`${sub.label} ${sub.keywords?.join(" ") ?? ""}`}
onSelect={() => navigate(sub.path)}
className={`${baseClass}__item ${baseClass}__item--sub`}
>
<span className={`${baseClass}__item-label`}>{sub.label}</span>
</Command.Item>
))}
</React.Fragment>
);
};
// Collect exact match items for the "Best match" section
const exactMatchItems = useMemo(() => {
if (exactMatchIds.size === 0) return [];
return items.reduce<Array<{ item: ICommandItem; sub?: ICommandSubItem }>>(
(acc, item) => {
if (exactMatchIds.has(item.id)) {
acc.push({ item });
}
item.subItems
?.filter((sub) => exactMatchIds.has(sub.id))
.forEach((sub) => acc.push({ item, sub }));
return acc;
},
[]
);
}, [items, exactMatchIds]);
const renderRootPage = () => (
<>
{/* Exact match at the top with a separator */}
{exactMatchItems.length > 0 && (
<>
<Command.Group heading="Best match" className={`${baseClass}__group`}>
{exactMatchItems.map(({ item, sub }) => {
const target = sub || item;
return (
<Command.Item
key={`exact-${target.id}`}
value={`EXACT_MATCH ${target.label}`}
onSelect={() =>
item.onAction ? item.onAction() : navigate(target.path!)
}
className={`${baseClass}__item`}
>
<span className={`${baseClass}__item-label`}>
{target.label}
</span>
</Command.Item>
);
})}
</Command.Group>
<Command.Separator className={`${baseClass}__separator`} />
</>
)}
{GROUPS.map((group) => {
const groupItems = groupedItems[group];
if (!groupItems?.length) {
return null;
}
return (
<Command.Group
key={group}
heading={group}
className={`${baseClass}__group`}
>
{groupItems.map(renderItem)}
</Command.Group>
);
})}
</>
);
const handleSelectHost = useCallback((hostId: number) => {
// Match ManageHostsPage.handleRowSelect — navigate without fleet_id so
// we don't switch the user's current team context. The host details
// page reads the host's team from the host record itself.
setOpen(false);
browserHistory.push(paths.HOST_DETAILS(hostId));
}, []);
const handleSelectSoftware = useCallback(
(softwareId: number) => {
setOpen(false);
const basePath = paths.SOFTWARE_TITLE_DETAILS(String(softwareId));
// Software titles are team-scoped; the destination page reads fleet_id
// from the URL. Pass the user's current team so we don't drop them on
// "All fleets" view by accident. Unassigned (id 0) is preserved too.
if (currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID) {
browserHistory.push(`${basePath}?fleet_id=${currentTeam.id}`);
} else {
browserHistory.push(basePath);
}
},
[currentTeam]
);
const handleSelectReport = useCallback(
(reportId: number) => {
setOpen(false);
const basePath = paths.REPORT_DETAILS(reportId);
if (currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID) {
browserHistory.push(`${basePath}?fleet_id=${currentTeam.id}`);
} else {
browserHistory.push(basePath);
}
},
[currentTeam]
);
const handleSelectPolicy = useCallback(
(policyId: number) => {
setOpen(false);
const basePath = paths.POLICY_DETAILS(policyId);
if (currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID) {
browserHistory.push(`${basePath}?fleet_id=${currentTeam.id}`);
} else {
browserHistory.push(basePath);
}
},
[currentTeam]
);
if (isNoAccess) {
return null;
}
return (
<Command.Dialog
open={open}
onOpenChange={handleOpenChange}
onValueChange={handleHighlightChange}
label="Command palette"
className={baseClass}
overlayClassName={`${baseClass}__overlay`}
contentClassName={`${baseClass}__content`}
filter={(value, searchTerm) => {
// Always show exact match items at the top
if (value.startsWith("EXACT_MATCH ")) {
return 1;
}
// Picker results are pre-filtered by the server; show everything.
if (isPreFilteredResult(value)) {
return 1;
}
// Default cmdk filtering
if (value.toLowerCase().includes(searchTerm.toLowerCase())) {
return 1;
}
return 0;
}}
>
<div className={`${baseClass}__input-wrapper`}>
{page !== "root" && (
<button
type="button"
aria-label="Back"
className={`${baseClass}__back-button`}
onClick={goBack}
>
<Icon name="arrow-left" color="ui-fleet-black-75" />
</button>
)}
<Command.Input
ref={inputRef}
className={`${baseClass}__input`}
placeholder={subPagePlaceholder ?? "Search for a page or command..."}
value={search}
onValueChange={setSearch}
onKeyDown={onKeyDown}
/>
{page === "root" && canSwitchFleet && (
<button
type="button"
// Locks the accessible name to the team name so the kbd
// shortcut pills (aria-hidden) can't pollute it later if
// the markup changes.
aria-label={`Switch fleet (currently ${fleetSwitcherLabel})`}
className={`${baseClass}__fleet-switcher`}
onClick={() => goToPage("switch-fleet")}
onKeyDown={(e) => {
// Stop Enter from bubbling to cmdk-root, which would
// activate whichever list item is currently highlighted.
// The button's native Enter still triggers the click above.
if (e.key === "Enter") {
e.stopPropagation();
}
}}
>
<span className={`${baseClass}__fleet-switcher-label`}>
{fleetSwitcherLabel}
</span>
<span
aria-hidden
className={`${baseClass}__fleet-switcher-shortcut`}
>
<kbd className={`${baseClass}__shortcut-key`}>
{isMacPlatform ? "⌘" : "Ctrl"}
</kbd>
<span className={`${baseClass}__shortcut-sep`}>+</span>
<kbd className={`${baseClass}__shortcut-key`}></kbd>
<span className={`${baseClass}__shortcut-sep`}>+</span>
<kbd className={`${baseClass}__shortcut-key`}>F</kbd>
</span>
</button>
)}
{page !== "root" && <kbd className={`${baseClass}__esc-hint`}>ESC</kbd>}
</div>
{/* Announce sub-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}$/, "") ?? ""}
</div>
<Command.List className={`${baseClass}__list`}>
{/* Sub-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`}>
No results found.
</Command.Empty>
)}
{page === "root" && renderRootPage()}
{page === "switch-fleet" && (
<FleetPicker
availableTeams={availableTeams}
currentTeam={currentTeam}
onSelect={handleSwitchFleet}
/>
)}
{page === "view-host" && (
<HostPicker
search={search}
showTeamColumn={!!isPremiumTier && !isPrimoMode}
onSelect={handleSelectHost}
/>
)}
{page === "view-software" && (
<SoftwarePicker
search={search}
currentTeam={currentTeam}
onSelect={handleSelectSoftware}
/>
)}
{page === "view-software-library" && (
<SoftwarePicker
search={search}
currentTeam={currentTeam}
scope="library"
onSelect={handleSelectSoftware}
/>
)}
{page === "view-report" && (
<ReportPicker
search={search}
currentTeam={currentTeam}
isViewerObserver={isViewerObserverInScope}
onSelect={handleSelectReport}
/>
)}
{page === "view-policy" && (
<PolicyPicker
search={search}
currentTeam={currentTeam}
isPremiumTier={!!isPremiumTier}
onSelect={handleSelectPolicy}
/>
)}
</Command.List>
</Command.Dialog>
);
};
export default CommandPalette;
@@ -0,0 +1,354 @@
.command-palette {
// Overlay backdrop
&__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99999;
background-color: rgba(0, 0, 0, 0.25);
animation: command-palette-fade-in 150ms ease-out;
}
// Radix Dialog content wrapper (the div[role="dialog"])
&__content {
position: fixed;
top: 20%;
left: 50%;
transform: translateX(-50%);
z-index: 100000;
width: 600px;
max-width: 75vw;
animation: command-palette-fade-in 150ms ease-out;
}
// cmdk root (inside the content wrapper)
&[cmdk-root] {
background-color: $core-fleet-white;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius-large; // Match modal border radius
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
&__input-wrapper {
position: relative;
display: flex;
align-items: center;
padding: 0 $pad-small;
border-bottom: 1px solid $ui-fleet-black-10;
}
&__back-button {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: none;
border: none;
border-radius: $border-radius;
cursor: pointer;
&:hover {
background-color: $ui-fleet-black-5;
}
// box-shadow ring instead of `outline + outline-offset` so the right
// edge isn't clipped by the adjacent input element.
&:focus-visible {
outline: none;
box-shadow: 0 0 0 1px $core-focused-outline;
}
}
&__input {
flex: 1;
min-width: 0;
box-sizing: border-box;
height: 44px;
padding: $pad-small;
font-size: $x-small;
color: $core-fleet-black;
background-color: $core-fleet-white;
border: none;
outline: none;
&::placeholder {
color: $ui-fleet-black-50;
}
}
&__esc-hint {
flex-shrink: 0;
display: flex;
height: 20px;
min-width: 20px;
padding: 0 $pad-xsmall;
flex-direction: column;
justify-content: center;
align-items: center;
font-family: inherit;
font-size: $xxx-small;
font-weight: $bold;
color: $ui-fleet-black-75;
background-color: $ui-fleet-black-5;
border: 1px solid $ui-fleet-black-25;
border-radius: $border-radius;
letter-spacing: 0.5px;
}
&__fleet-switcher {
flex-shrink: 0;
display: flex;
align-items: center;
gap: $pad-small;
padding: $pad-xsmall $pad-small;
border: 1px solid $ui-fleet-black-25;
background: $core-fleet-white;
font-size: $x-small;
color: $core-fleet-black;
cursor: pointer;
white-space: nowrap;
border-radius: $border-radius;
max-width: 320px;
// In dark mode $core-fleet-white equals the dialog bg, so the outlined
// button visually merges with the surface. Bump it one tier up.
body.dark-mode & {
background: $ui-fleet-black-5;
}
&:hover {
border-color: $ui-fleet-black-50;
}
&:focus-visible {
outline: none;
border-color: $core-focused-outline;
}
}
&__fleet-switcher-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__fleet-switcher-shortcut {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 2px;
}
&__shortcut-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0;
font-family: inherit;
font-size: $xxx-small;
font-weight: $bold;
color: $ui-fleet-black-75;
background-color: $ui-fleet-black-5;
border: 1px solid $ui-fleet-black-25;
border-radius: $border-radius;
line-height: 1;
}
&__shortcut-sep {
font-size: $xxx-small;
color: $ui-fleet-black-50;
user-select: none;
}
&__list {
// ~8 items visible before scrolling
max-height: 320px;
overflow-x: hidden;
overflow-y: auto;
padding: $pad-small;
border-radius: $border-radius $border-radius 0 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: $ui-fleet-black-25;
border-radius: 3px;
}
}
&__empty {
padding: $pad-large $pad-medium;
text-align: center;
color: $ui-fleet-black-50;
font-size: $x-small;
}
&__group {
[cmdk-group-heading] {
padding: 10px $pad-small;
font-size: $xx-small;
font-weight: $bold;
color: $core-fleet-black;
pointer-events: none;
user-select: none;
}
}
&__separator {
height: 1px;
background-color: $ui-fleet-black-10;
margin: $pad-small 0;
}
&__item {
padding: 10px $pad-small;
font-size: $x-small;
color: $core-fleet-black;
background-color: transparent;
cursor: pointer;
border-radius: $border-radius;
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
transition: background-color 100ms;
&[data-selected="true"] {
background-color: $ui-fleet-black-5;
}
&:active {
background-color: $ui-fleet-black-10;
}
}
&__item--sub {
padding-left: $pad-medium;
}
// HostPicker rows: a small status dot sits inline with the host name;
// the optional team column (premium + !primo only) is pushed to the
// right via the default __item flex / space-between.
&__host-name {
display: flex;
align-items: center;
gap: $pad-small;
min-width: 0;
flex: 1;
overflow: hidden;
}
&__host-status-dot {
flex-shrink: 0;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 100%;
background-color: $ui-fleet-black-25;
&--online,
&--new {
background-color: $ui-success;
}
&--offline,
&--missing {
background-color: $ui-offline;
}
}
&__host-team {
flex-shrink: 0;
width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: $x-small;
color: $ui-fleet-black-50;
font-style: italic;
text-align: right;
}
&__item-left {
display: flex;
align-items: center;
min-width: 0;
gap: $pad-small;
}
&__item-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--selected {
font-weight: $bold;
}
}
&__item-more {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
// Spacing from the adjacent label comes from __item-left's gap.
padding: $pad-xsmall $pad-small;
background: none;
border: none;
border-radius: $border-radius;
cursor: pointer;
transition: background-color 150ms ease;
.icon {
transition: transform 200ms ease;
}
&:hover {
background-color: $ui-fleet-black-10;
}
// No :focus-visible rule: both consumers are non-focusable —
// chevron-down button uses tabIndex={-1} and chevron-right is
// aria-hidden. Keyboard users discover sub-items via arrow-key
// auto-expand (handleHighlightChange → valueToParentId).
&--expanded .icon {
transform: rotate(180deg);
}
}
&__item-fleet,
&__item-meta {
flex-shrink: 0;
margin-left: auto;
padding-left: $pad-medium;
font-size: $x-small;
color: $ui-fleet-black-50;
font-style: italic;
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@keyframes command-palette-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@@ -0,0 +1,74 @@
import React from "react";
import { screen } from "@testing-library/react";
import { Command } from "cmdk";
import { createCustomRenderer } from "test/test-utils";
import FleetPicker from "./FleetPicker";
// cmdk uses scrollIntoView which JSDOM doesn't implement
Element.prototype.scrollIntoView = jest.fn();
const renderInCommand = createCustomRenderer();
const renderPicker = (
component: React.ReactElement
): ReturnType<typeof renderInCommand> =>
renderInCommand(<Command>{component}</Command>);
describe("FleetPicker", () => {
const availableTeams = [
{ id: -1, name: "All fleets" },
{ id: 0, name: "No team" },
{ id: 1, name: "Engineering" },
{ id: 2, name: "Sales" },
];
it("renders every fleet in availableTeams", () => {
renderPicker(
<FleetPicker
availableTeams={availableTeams}
currentTeam={availableTeams[2]}
onSelect={jest.fn()}
/>
);
expect(screen.getByText("All fleets")).toBeInTheDocument();
expect(screen.getByText("No team")).toBeInTheDocument();
expect(screen.getByText("Engineering")).toBeInTheDocument();
expect(screen.getByText("Sales")).toBeInTheDocument();
});
it("marks the current fleet with the selected modifier class", () => {
renderPicker(
<FleetPicker
availableTeams={availableTeams}
currentTeam={availableTeams[2]}
onSelect={jest.fn()}
/>
);
const engineering = screen.getByText("Engineering");
expect(engineering.className).toMatch(/__item-label--selected/);
const sales = screen.getByText("Sales");
expect(sales.className).not.toMatch(/__item-label--selected/);
});
it("calls onSelect with the fleet id when an item is clicked", async () => {
const onSelect = jest.fn();
const { user } = renderPicker(
<FleetPicker
availableTeams={availableTeams}
currentTeam={availableTeams[2]}
onSelect={onSelect}
/>
);
await user.click(screen.getByText("Sales"));
expect(onSelect).toHaveBeenCalledWith(2);
});
it("renders empty (no items) when availableTeams is undefined", () => {
renderPicker(<FleetPicker onSelect={jest.fn()} />);
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,44 @@
import React from "react";
import { Command } from "cmdk";
import { ITeamSummary } from "interfaces/team";
const baseClass = "command-palette";
interface IFleetPickerProps {
availableTeams?: ITeamSummary[];
currentTeam?: ITeamSummary;
onSelect: (fleetId: number) => void;
}
const FleetPicker = ({
availableTeams,
currentTeam,
onSelect,
}: IFleetPickerProps): JSX.Element => {
return (
<Command.Group className={`${baseClass}__group`}>
{availableTeams?.map((fleet) => {
const isSelected = fleet.id === currentTeam?.id;
return (
<Command.Item
key={`fleet-${fleet.id}`}
value={fleet.name}
onSelect={() => onSelect(fleet.id)}
className={`${baseClass}__item`}
>
<span
className={`${baseClass}__item-label${
isSelected ? ` ${baseClass}__item-label--selected` : ""
}`}
>
{fleet.name}
</span>
</Command.Item>
);
})}
</Command.Group>
);
};
export default FleetPicker;
@@ -0,0 +1,153 @@
import React from "react";
import { waitFor } from "@testing-library/react";
import { Command } from "cmdk";
import { createCustomRenderer } from "test/test-utils";
import hostsAPI, { ILoadHostsResponse } from "services/entities/hosts";
import HostPicker from "./HostPicker";
// cmdk uses scrollIntoView which JSDOM doesn't implement
Element.prototype.scrollIntoView = jest.fn();
jest.mock("services/entities/hosts", () => ({
__esModule: true,
default: { loadHosts: jest.fn() },
}));
const mockedHosts = hostsAPI as jest.Mocked<typeof hostsAPI>;
const renderInClient = createCustomRenderer({ withBackendMock: true });
// Command.Item needs a Command root in context; the parent dialog supplies
// one in production, so wrap here for tests that actually render items.
const renderPicker = (
ui: React.ReactElement
): ReturnType<typeof renderInClient> => renderInClient(<Command>{ui}</Command>);
// Minimal valid response — the picker only reads `hosts`, so we cast
// through `unknown` rather than constructing the unused munki/MDM
// aggregates. The typed local lets future renames of the field this
// test cares about surface here.
const emptyHostsResponse: ILoadHostsResponse = ({
hosts: [],
} as unknown) as ILoadHostsResponse;
const hostsResponseWith = (
...hosts: Array<{
id: number;
display_name: string;
status: string;
team_id: number | null;
team_name: string | null;
}>
): ILoadHostsResponse =>
(({
hosts,
} as unknown) as ILoadHostsResponse);
beforeEach(() => {
mockedHosts.loadHosts.mockReset();
mockedHosts.loadHosts.mockResolvedValue(emptyHostsResponse);
});
describe("HostPicker", () => {
it("calls loadHosts WITHOUT teamId (global navigator, no scoping)", async () => {
renderPicker(<HostPicker search="" onSelect={jest.fn()} />);
await waitFor(() => {
expect(mockedHosts.loadHosts).toHaveBeenCalled();
});
// Confirm the teamId key is absent entirely — `expect.anything()`
// matches values but skips null/undefined, so it can't catch a
// future regression that passes `teamId: undefined`.
const callArgs = mockedHosts.loadHosts.mock.calls[0][0] as Record<
string,
unknown
>;
expect(Object.keys(callArgs)).not.toContain("teamId");
});
it("passes search as globalFilter and sorts by display_name asc", async () => {
renderPicker(<HostPicker search="rachel" onSelect={jest.fn()} />);
await waitFor(() => {
expect(mockedHosts.loadHosts).toHaveBeenCalledWith(
expect.objectContaining({
globalFilter: "rachel",
sortBy: [{ key: "display_name", direction: "asc" }],
})
);
});
});
it("renders the no-search empty state when no hosts return", async () => {
const { findByText } = renderPicker(
<HostPicker search="" onSelect={jest.fn()} />
);
expect(await findByText(/No hosts found\./)).toBeInTheDocument();
});
it("renders a search-specific empty state when a debounced query returns nothing", async () => {
const { findByText } = renderPicker(
<HostPicker search="nonexistent" onSelect={jest.fn()} />
);
expect(
await findByText(/No hosts match "nonexistent"\./)
).toBeInTheDocument();
});
describe("columns", () => {
const hosts = hostsResponseWith({
id: 1,
display_name: "Rachel's MacBook",
status: "online",
team_id: 5,
team_name: "Engineering",
});
// The shared QueryClient persists React Query's cache across tests
// in this file. Earlier tests register an empty result under
// queryKey ["commandPaletteHosts", ""], so subsequent renders with
// the same search would read that cached emptiness and never hit
// the mock. Each column test uses a unique search string to get a
// fresh queryFn invocation. The mock ignores the query value, so
// the same `hosts` is returned regardless.
it("renders a status dot next to the host name (no text)", async () => {
mockedHosts.loadHosts.mockResolvedValue(hosts);
const { findByText, container } = renderPicker(
<HostPicker search="col-dot-test" onSelect={jest.fn()} />
);
expect(await findByText("Rachel's MacBook")).toBeInTheDocument();
// The dot is a presentational span; assert by class so the test
// pins both the existence and the status-specific modifier.
const dot = container.querySelector(
".command-palette__host-status-dot--online"
);
expect(dot).toBeInTheDocument();
// No status text rendered alongside the dot.
expect(container.textContent).not.toMatch(/Online/);
});
it("renders the host's team in the right-aligned column when showTeamColumn", async () => {
mockedHosts.loadHosts.mockResolvedValue(hosts);
const { findByText } = renderPicker(
<HostPicker search="col-team-on" showTeamColumn onSelect={jest.fn()} />
);
expect(await findByText("Engineering")).toBeInTheDocument();
});
it("suppresses the team column by default (Free / Primo / single-fleet)", async () => {
mockedHosts.loadHosts.mockResolvedValue(hosts);
const { findByText, queryByText } = renderPicker(
<HostPicker search="col-team-off" onSelect={jest.fn()} />
);
// Name + dot still render; team does not.
expect(await findByText("Rachel's MacBook")).toBeInTheDocument();
expect(queryByText("Engineering")).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,93 @@
import React from "react";
import { Command } from "cmdk";
import hostsAPI, { ILoadHostsResponse } from "services/entities/hosts";
import usePickerSearch from "./usePickerSearch";
import { RESULT_PREFIXES } from "./constants";
const baseClass = "command-palette";
const HOST_SEARCH_LIMIT = 50;
interface IHostPickerProps {
search: string;
/** When true, render the host's team in a third column. Caller is
* responsible for enforcing premium-tier + non-Primo (single-fleet
* installs have nothing meaningful to show in the team column). */
showTeamColumn?: boolean;
onSelect: (hostId: number) => void;
}
const HostPicker = ({
search,
showTeamColumn = false,
onSelect,
}: IHostPickerProps): JSX.Element => {
// No team scoping — the picker is a global navigator. On select, the
// parent navigates to /hosts/:id/details without fleet_id; the host
// details page reads the host's team from the host record itself, so
// the user's current team context is preserved (matches the
// ManageHostsPage.handleRowSelect pattern).
const { items: hosts, isLoading, debouncedQuery } = usePickerSearch<
ILoadHostsResponse,
ILoadHostsResponse["hosts"][number]
>({
search,
queryKeyPrefix: ["commandPaletteHosts"],
queryFn: (q) =>
hostsAPI.loadHosts({
page: 0,
perPage: HOST_SEARCH_LIMIT,
globalFilter: q || undefined,
sortBy: [{ key: "display_name", direction: "asc" }],
}),
selectItems: (data) => data?.hosts ?? [],
});
if (isLoading && hosts.length === 0) {
return <div className={`${baseClass}__empty`}>Looking for hosts...</div>;
}
if (hosts.length === 0) {
return (
<div className={`${baseClass}__empty`}>
{debouncedQuery
? `No hosts match "${debouncedQuery}".`
: "No hosts found."}
</div>
);
}
return (
<Command.Group className={`${baseClass}__group`}>
{hosts.map((host) => {
const label = host.display_name || host.hostname || `Host ${host.id}`;
const dotClass = `${baseClass}__host-status-dot ${baseClass}__host-status-dot--${host.status}`;
return (
<Command.Item
key={`host-${host.id}`}
value={`${RESULT_PREFIXES.host}${host.id}`}
onSelect={() => onSelect(host.id)}
className={`${baseClass}__item`}
>
<span className={`${baseClass}__host-name`}>
<span
className={dotClass}
aria-label={`status: ${host.status}`}
/>
<span className={`${baseClass}__item-label`}>{label}</span>
</span>
{showTeamColumn && (
<span className={`${baseClass}__host-team`}>
{host.team_name ?? ""}
</span>
)}
</Command.Item>
);
})}
</Command.Group>
);
};
export default HostPicker;
@@ -0,0 +1,200 @@
import React from "react";
import { waitFor } from "@testing-library/react";
import { Command } from "cmdk";
import { createCustomRenderer } from "test/test-utils";
import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import { IPolicyStats } from "interfaces/policy";
import PolicyPicker from "./PolicyPicker";
// cmdk uses scrollIntoView which JSDOM doesn't implement.
Element.prototype.scrollIntoView = jest.fn();
jest.mock("services/entities/global_policies", () => ({
__esModule: true,
default: { loadAllNew: jest.fn() },
}));
jest.mock("services/entities/team_policies", () => ({
__esModule: true,
default: { loadAllNew: jest.fn() },
}));
const mockedGlobal = globalPoliciesAPI as jest.Mocked<typeof globalPoliciesAPI>;
const mockedTeam = teamPoliciesAPI as jest.Mocked<typeof teamPoliciesAPI>;
const renderInClient = createCustomRenderer({ withBackendMock: true });
const renderPicker: typeof renderInClient = (ui, options) =>
renderInClient(ui, options);
// Some tests render Command.Items, which need a Command root in
// context; the parent dialog supplies one in production.
const renderPickerInCommand = (
ui: React.ReactElement
): ReturnType<typeof renderInClient> => renderInClient(<Command>{ui}</Command>);
// Minimal IPolicyStats — the picker only reads id, name, type, team_id,
// and critical. Cast through `unknown` to skip the full shape.
const policyWith = (
fields: Partial<IPolicyStats> & Pick<IPolicyStats, "id" | "name">
): IPolicyStats =>
(({
team_id: null,
type: "",
critical: false,
...fields,
} as unknown) as IPolicyStats);
beforeEach(() => {
mockedGlobal.loadAllNew.mockReset();
mockedTeam.loadAllNew.mockReset();
mockedGlobal.loadAllNew.mockResolvedValue({ policies: [] });
mockedTeam.loadAllNew.mockResolvedValue({ policies: [] });
});
describe("PolicyPicker", () => {
it("calls globalPoliciesAPI when currentTeam is All fleets", async () => {
renderPicker(
<PolicyPicker
search=""
currentTeam={{ id: -1, name: "All fleets" }}
onSelect={jest.fn()}
/>
);
await waitFor(() => {
expect(mockedGlobal.loadAllNew).toHaveBeenCalled();
});
expect(mockedTeam.loadAllNew).not.toHaveBeenCalled();
});
it("calls teamPoliciesAPI with mergeInherited when a real team is selected", async () => {
renderPicker(
<PolicyPicker
search=""
currentTeam={{ id: 5, name: "Engineering" }}
onSelect={jest.fn()}
/>
);
await waitFor(() => {
expect(mockedTeam.loadAllNew).toHaveBeenCalledWith(
expect.objectContaining({ teamId: 5, mergeInherited: true })
);
});
expect(mockedGlobal.loadAllNew).not.toHaveBeenCalled();
});
it("calls teamPoliciesAPI with teamId 0 for Unassigned", async () => {
renderPicker(
<PolicyPicker
search=""
currentTeam={{ id: 0, name: "No team" }}
onSelect={jest.fn()}
/>
);
await waitFor(() => {
expect(mockedTeam.loadAllNew).toHaveBeenCalledWith(
expect.objectContaining({ teamId: 0 })
);
});
});
it("renders the team-scoped empty state when no team matches", async () => {
mockedTeam.loadAllNew.mockResolvedValue({ policies: [] });
const { findByText } = renderPicker(
<PolicyPicker
search=""
currentTeam={{ id: 5, name: "Engineering" }}
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No policies found in Engineering\./)
).toBeInTheDocument();
});
it("renders 'in this fleet' empty state when Unassigned is selected", async () => {
const { findByText } = renderPicker(
<PolicyPicker
search=""
currentTeam={{ id: 0, name: "Unassigned" }}
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No policies found in this fleet\./)
).toBeInTheDocument();
});
it("renders a fleet-less empty state on All fleets", async () => {
const { findByText } = renderPicker(
<PolicyPicker
search=""
currentTeam={{ id: -1, name: "All fleets" }}
onSelect={jest.fn()}
/>
);
expect(await findByText(/^No policies found\.$/)).toBeInTheDocument();
});
it("renders a search-specific empty state with query but no fleet suffix on All fleets", async () => {
const { findByText } = renderPicker(
<PolicyPicker
search="missing"
currentTeam={{ id: -1, name: "All fleets" }}
onSelect={jest.fn()}
/>
);
expect(
await findByText(/^No policies match "missing"\.$/)
).toBeInTheDocument();
});
describe("Patch badge", () => {
// Unique search values per test sidestep React Query cache pollution
// from earlier empty-state tests, which registered an empty result
// under queryKey ["commandPalettePolicies", ..., "<search>"].
it("renders the Patch badge when policy.type === 'patch'", async () => {
mockedGlobal.loadAllNew.mockResolvedValue({
policies: [
policyWith({ id: 1, name: "Outdated Chrome", type: "patch" }),
],
});
const { findByText } = renderPickerInCommand(
<PolicyPicker
search="patch-on"
currentTeam={{ id: -1, name: "All fleets" }}
onSelect={jest.fn()}
/>
);
expect(await findByText("Outdated Chrome")).toBeInTheDocument();
expect(await findByText("Patch")).toBeInTheDocument();
});
it("omits the Patch badge for non-patch policies", async () => {
mockedGlobal.loadAllNew.mockResolvedValue({
policies: [policyWith({ id: 2, name: "Disk encryption", type: "" })],
});
const { findByText, queryByText } = renderPickerInCommand(
<PolicyPicker
search="patch-off"
currentTeam={{ id: -1, name: "All fleets" }}
onSelect={jest.fn()}
/>
);
expect(await findByText("Disk encryption")).toBeInTheDocument();
expect(queryByText("Patch")).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,125 @@
import React from "react";
import { Command } from "cmdk";
import { APP_CONTEXT_ALL_TEAMS_ID, ITeamSummary } from "interfaces/team";
import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import {
ILoadAllPoliciesResponse,
ILoadTeamPoliciesResponse,
IPolicyStats,
} from "interfaces/policy";
import CriticalPolicyBadge from "components/CriticalPolicyBadge";
import PillBadge from "components/PillBadge";
import { PATCH_TOOLTIP_CONTENT } from "components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges";
import usePickerSearch from "./usePickerSearch";
import { RESULT_PREFIXES } from "./constants";
import getFleetSuffix from "./pickerCopy";
const baseClass = "command-palette";
const POLICY_SEARCH_LIMIT = 50;
interface IPolicyPickerProps {
search: string;
currentTeam?: ITeamSummary;
/** Critical-policy badge is Premium-only (matches PoliciesTable). */
isPremiumTier?: boolean;
onSelect: (policyId: number) => void;
}
const PolicyPicker = ({
search,
currentTeam,
isPremiumTier = false,
onSelect,
}: IPolicyPickerProps): JSX.Element => {
const teamId =
currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID
? currentTeam.id
: undefined;
const fleetSuffix = getFleetSuffix(currentTeam);
const { items: policies, isLoading, debouncedQuery } = usePickerSearch<
ILoadAllPoliciesResponse | ILoadTeamPoliciesResponse,
IPolicyStats
>({
search,
queryKeyPrefix: ["commandPalettePolicies", teamId ?? "global"],
queryFn: (q) => {
if (teamId !== undefined) {
return teamPoliciesAPI.loadAllNew({
teamId,
page: 0,
perPage: POLICY_SEARCH_LIMIT,
query: q || undefined,
// Surface inherited global policies in team views, matching the
// Policies page behavior.
mergeInherited: true,
});
}
return globalPoliciesAPI.loadAllNew({
page: 0,
perPage: POLICY_SEARCH_LIMIT,
query: q || undefined,
});
},
selectItems: (data) => data?.policies ?? [],
});
if (isLoading && policies.length === 0) {
return <div className={`${baseClass}__empty`}>Looking for policies...</div>;
}
if (policies.length === 0) {
return (
<div className={`${baseClass}__empty`}>
{debouncedQuery
? `No policies match "${debouncedQuery}"${fleetSuffix}.`
: `No policies found${fleetSuffix}.`}
</div>
);
}
// "Inherited" applies only when viewing a specific team and the policy
// is a global one (team_id === null), matching PoliciesTableConfig.
const isViewingSpecificTeam =
!!currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID;
return (
<Command.Group className={`${baseClass}__group`}>
{policies.map((policy) => {
const showCriticalBadge = isPremiumTier && policy.critical;
const showPatchBadge = policy.type === "patch";
const showInheritedBadge =
isViewingSpecificTeam && policy.team_id === null;
return (
<Command.Item
key={`policy-${policy.id}`}
value={`${RESULT_PREFIXES.policy}${policy.id}`}
onSelect={() => onSelect(policy.id)}
className={`${baseClass}__item`}
>
<div className={`${baseClass}__item-left`}>
<span className={`${baseClass}__item-label`}>{policy.name}</span>
{showCriticalBadge && <CriticalPolicyBadge />}
{showPatchBadge && (
<PillBadge tipContent={PATCH_TOOLTIP_CONTENT}>Patch</PillBadge>
)}
{showInheritedBadge && (
<PillBadge tipContent="This policy runs on all hosts.">
Inherited
</PillBadge>
)}
</div>
</Command.Item>
);
})}
</Command.Group>
);
};
export default PolicyPicker;
@@ -0,0 +1,124 @@
import React from "react";
import { waitFor } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import queriesAPI, { IQueriesResponse } from "services/entities/queries";
import ReportPicker from "./ReportPicker";
jest.mock("services/entities/queries", () => ({
__esModule: true,
default: { loadAll: jest.fn() },
}));
const mockedQueries = queriesAPI as jest.Mocked<typeof queriesAPI>;
const renderPicker = createCustomRenderer({ withBackendMock: true });
// Minimal valid response — typed so a future IQueriesResponse rename
// would surface here instead of being hidden by `as any`.
const emptyQueriesResponse: IQueriesResponse = {
queries: [],
count: 0,
inherited_query_count: 0,
meta: { has_next_results: false, has_previous_results: false },
};
beforeEach(() => {
mockedQueries.loadAll.mockReset();
mockedQueries.loadAll.mockResolvedValue(emptyQueriesResponse);
});
describe("ReportPicker", () => {
it("scopes by currentTeam when a real team is selected, with mergeInherited", async () => {
renderPicker(
<ReportPicker
search=""
currentTeam={{ id: 5, name: "Engineering" }}
onSelect={jest.fn()}
/>
);
await waitFor(() => {
expect(mockedQueries.loadAll).toHaveBeenCalledWith(
expect.objectContaining({
teamId: 5,
mergeInherited: true,
scope: "queries",
})
);
});
});
it("passes teamId undefined when currentTeam is All fleets", async () => {
renderPicker(
<ReportPicker
search=""
currentTeam={{ id: -1, name: "All fleets" }}
onSelect={jest.fn()}
/>
);
await waitFor(() => {
expect(mockedQueries.loadAll).toHaveBeenCalledWith(
expect.objectContaining({ teamId: undefined })
);
});
});
it("renders the fleet-scoped empty state", async () => {
const { findByText } = renderPicker(
<ReportPicker
search=""
currentTeam={{ id: 5, name: "Engineering" }}
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No reports found in Engineering\./)
).toBeInTheDocument();
});
it("renders the search-specific empty state with fleet label", async () => {
const { findByText } = renderPicker(
<ReportPicker
search="missing"
currentTeam={{ id: 5, name: "Engineering" }}
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No reports match "missing" in Engineering\./)
).toBeInTheDocument();
});
it("renders 'in this fleet' empty state when Unassigned is selected", async () => {
const { findByText } = renderPicker(
<ReportPicker
search=""
currentTeam={{ id: 0, name: "Unassigned" }}
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No reports found in this fleet\./)
).toBeInTheDocument();
});
it("renders a fleet-less empty state on All fleets", async () => {
const { findByText } = renderPicker(
<ReportPicker
search=""
currentTeam={{ id: -1, name: "All fleets" }}
onSelect={jest.fn()}
/>
);
// No suffix when context is All fleets.
const node = await findByText(/^No reports found\.$/);
expect(node).toBeInTheDocument();
});
});
@@ -0,0 +1,122 @@
import React from "react";
import { Command } from "cmdk";
import { APP_CONTEXT_ALL_TEAMS_ID, ITeamSummary } from "interfaces/team";
import queriesAPI, { IQueriesResponse } from "services/entities/queries";
import { ISchedulableQuery } from "interfaces/schedulable_query";
import Icon from "components/Icon";
import PillBadge from "components/PillBadge";
import TooltipWrapper from "components/TooltipWrapper";
import usePickerSearch from "./usePickerSearch";
import { RESULT_PREFIXES } from "./constants";
import getFleetSuffix from "./pickerCopy";
const baseClass = "command-palette";
const REPORT_SEARCH_LIMIT = 50;
interface IReportPickerProps {
search: string;
currentTeam?: ITeamSummary;
/** True when the viewer is an observer in the current team scope.
* Suppresses the "Observers can run" indicator on those reports — the
* badge is meant to flag reports that *non-observers* can hand off to
* observers, not to advertise the current user's own capability. */
isViewerObserver?: boolean;
onSelect: (reportId: number) => void;
}
const ReportPicker = ({
search,
currentTeam,
isViewerObserver = false,
onSelect,
}: IReportPickerProps): JSX.Element => {
const teamId =
currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID
? currentTeam.id
: undefined;
const fleetSuffix = getFleetSuffix(currentTeam);
const { items: reports, isLoading, debouncedQuery } = usePickerSearch<
IQueriesResponse,
ISchedulableQuery
>({
search,
queryKeyPrefix: ["commandPaletteReports", teamId ?? "global"],
queryFn: (q) =>
queriesAPI.loadAll({
scope: "queries",
teamId,
page: 0,
perPage: REPORT_SEARCH_LIMIT,
query: q || undefined,
orderKey: "name",
orderDirection: "asc",
mergeInherited: true,
}),
selectItems: (data) => data?.queries ?? [],
});
if (isLoading && reports.length === 0) {
return <div className={`${baseClass}__empty`}>Looking for reports...</div>;
}
if (reports.length === 0) {
return (
<div className={`${baseClass}__empty`}>
{debouncedQuery
? `No reports match "${debouncedQuery}"${fleetSuffix}.`
: `No reports found${fleetSuffix}.`}
</div>
);
}
// "Inherited" applies only when viewing a specific team and the report
// lives at a different scope (typically global, team_id null/different).
const isViewingSpecificTeam =
!!currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID;
return (
<Command.Group className={`${baseClass}__group`}>
{reports.map((report) => {
const showObserverIcon = !isViewerObserver && report.observer_can_run;
const showInheritedBadge =
isViewingSpecificTeam && report.team_id !== currentTeam?.id;
return (
<Command.Item
key={`report-${report.id}`}
value={`${RESULT_PREFIXES.report}${report.id}`}
onSelect={() => onSelect(report.id)}
className={`${baseClass}__item`}
>
<div className={`${baseClass}__item-left`}>
<span className={`${baseClass}__item-label`}>{report.name}</span>
{showObserverIcon && (
<TooltipWrapper
tipContent="Observers can run this report."
underline={false}
showArrow
position="top"
delayInMs={300}
>
<Icon name="query" size="small" color="ui-fleet-black-50" />
</TooltipWrapper>
)}
{showInheritedBadge && (
<PillBadge tipContent="This report runs on all hosts.">
Inherited
</PillBadge>
)}
</div>
</Command.Item>
);
})}
</Command.Group>
);
};
export default ReportPicker;
@@ -0,0 +1,137 @@
import React from "react";
import { waitFor } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import softwareAPI from "services/entities/software";
import SoftwarePicker from "./SoftwarePicker";
jest.mock("services/entities/software", () => ({
__esModule: true,
default: { getSoftwareTitles: jest.fn() },
}));
const mockedSoftware = softwareAPI as jest.Mocked<typeof softwareAPI>;
const renderPicker = createCustomRenderer({ withBackendMock: true });
beforeEach(() => {
mockedSoftware.getSoftwareTitles.mockReset();
mockedSoftware.getSoftwareTitles.mockResolvedValue({
count: 0,
counts_updated_at: null,
software_titles: [],
meta: { has_next_results: false, has_previous_results: false },
});
});
describe("SoftwarePicker", () => {
it("calls getSoftwareTitles with availableForInstall=true in library scope", async () => {
renderPicker(
<SoftwarePicker
search=""
currentTeam={{ id: 5, name: "Engineering" }}
scope="library"
onSelect={jest.fn()}
/>
);
await waitFor(() => {
expect(mockedSoftware.getSoftwareTitles).toHaveBeenCalledWith(
expect.objectContaining({
teamId: 5,
availableForInstall: true,
})
);
});
});
it("calls getSoftwareTitles without availableForInstall in inventory scope", async () => {
renderPicker(
<SoftwarePicker
search=""
currentTeam={{ id: 5, name: "Engineering" }}
onSelect={jest.fn()}
/>
);
await waitFor(() => {
expect(mockedSoftware.getSoftwareTitles).toHaveBeenCalledWith(
expect.objectContaining({
teamId: 5,
availableForInstall: undefined,
})
);
});
});
it("renders the library empty state when no titles match in the fleet", async () => {
const { findByText } = renderPicker(
<SoftwarePicker
search=""
currentTeam={{ id: 5, name: "Engineering" }}
scope="library"
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No software in Engineering's library\./)
).toBeInTheDocument();
});
it("renders the inventory empty state when no titles match in the fleet", async () => {
const { findByText } = renderPicker(
<SoftwarePicker
search=""
currentTeam={{ id: 5, name: "Engineering" }}
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No software found in Engineering\./)
).toBeInTheDocument();
});
it("inventory empty state uses 'in this fleet' for Unassigned", async () => {
const { findByText } = renderPicker(
<SoftwarePicker
search=""
currentTeam={{ id: 0, name: "Unassigned" }}
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No software found in this fleet\./)
).toBeInTheDocument();
});
it("inventory empty state drops the suffix on All fleets", async () => {
const { findByText } = renderPicker(
<SoftwarePicker
search=""
currentTeam={{ id: -1, name: "All fleets" }}
onSelect={jest.fn()}
/>
);
expect(await findByText(/^No software found\.$/)).toBeInTheDocument();
});
it("library empty state uses 'this fleet's library' for Unassigned", async () => {
const { findByText } = renderPicker(
<SoftwarePicker
search=""
currentTeam={{ id: 0, name: "Unassigned" }}
scope="library"
onSelect={jest.fn()}
/>
);
expect(
await findByText(/No software in this fleet's library\./)
).toBeInTheDocument();
});
});
@@ -0,0 +1,144 @@
import React from "react";
import { Command } from "cmdk";
import {
APP_CONTEXT_ALL_TEAMS_ID,
APP_CONTEXT_NO_TEAM_ID,
ITeamSummary,
} from "interfaces/team";
import {
formatSoftwareType,
isIpadOrIphoneSoftwareSource,
ISoftwareTitle,
} from "interfaces/software";
import softwareAPI, {
ISoftwareTitlesResponse,
} from "services/entities/software";
import { getAutomaticInstallPoliciesCount } from "pages/SoftwarePage/helpers";
import { InstallIconWithTooltip } from "components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell";
import getFleetSuffix from "./pickerCopy";
import usePickerSearch from "./usePickerSearch";
import { RESULT_PREFIXES } from "./constants";
const baseClass = "command-palette";
const SOFTWARE_SEARCH_LIMIT = 50;
type SoftwareScope = "inventory" | "library";
// Derives the install-icon tooltip props from a software title, mirroring
// getSoftwareNameCellData in SoftwareLibraryTableConfig. Returns null when
// the title has no installer attached (not in any library).
const getInstallerProps = (title: ISoftwareTitle) => {
const installer = title.software_package || title.app_store_app;
if (!installer) {
return null;
}
return {
isSelfService: installer.self_service,
automaticInstallPoliciesCount: getAutomaticInstallPoliciesCount(title),
isIosOrIpadosApp: isIpadOrIphoneSoftwareSource(title.source),
isAndroidPlayStoreApp:
!!title.app_store_app && title.source === "android_apps",
};
};
interface ISoftwarePickerProps {
search: string;
currentTeam?: ITeamSummary;
scope?: SoftwareScope;
onSelect: (softwareId: number) => void;
}
const SoftwarePicker = ({
search,
currentTeam,
scope = "inventory",
onSelect,
}: ISoftwarePickerProps): JSX.Element => {
const teamId =
currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID
? currentTeam.id
: undefined;
const fleetSuffix = getFleetSuffix(currentTeam);
// For library copy ("No software in X's library.") we need a possessive
// form. Library is hidden on All fleets so that branch shouldn't
// render here, but we still default defensively.
const libraryOwner = (() => {
if (currentTeam && currentTeam.id > 0)
return `${currentTeam.name}'s library`;
if (currentTeam?.id === APP_CONTEXT_NO_TEAM_ID)
return "this fleet's library";
return "the library";
})();
const libraryOnly = scope === "library";
const { items: titles, isLoading, debouncedQuery } = usePickerSearch<
ISoftwareTitlesResponse,
ISoftwareTitle
>({
search,
queryKeyPrefix: ["commandPaletteSoftware", scope, teamId ?? "global"],
queryFn: (q) =>
softwareAPI.getSoftwareTitles({
page: 0,
perPage: SOFTWARE_SEARCH_LIMIT,
teamId,
availableForInstall: libraryOnly || undefined,
query: q || undefined,
orderKey: "name",
orderDirection: "asc",
}),
selectItems: (data) => data?.software_titles ?? [],
});
if (isLoading && titles.length === 0) {
return <div className={`${baseClass}__empty`}>Looking for software...</div>;
}
if (titles.length === 0) {
let emptyMessage: string;
if (libraryOnly) {
emptyMessage = debouncedQuery
? `No library software matches "${debouncedQuery}" in ${libraryOwner}.`
: `No software in ${libraryOwner}.`;
} else {
emptyMessage = debouncedQuery
? `No software matches "${debouncedQuery}"${fleetSuffix}.`
: `No software found${fleetSuffix}.`;
}
return <div className={`${baseClass}__empty`}>{emptyMessage}</div>;
}
return (
<Command.Group className={`${baseClass}__group`}>
{titles.map((title) => {
const label = title.display_name || title.name;
const typeLabel = formatSoftwareType(title);
const installerProps = getInstallerProps(title);
return (
<Command.Item
key={`software-${title.id}`}
value={`${RESULT_PREFIXES.software}${title.id}`}
onSelect={() => onSelect(title.id)}
className={`${baseClass}__item`}
>
<div className={`${baseClass}__item-left`}>
<span className={`${baseClass}__item-label`}>{label}</span>
{installerProps && <InstallIconWithTooltip {...installerProps} />}
</div>
{typeLabel && (
<span className={`${baseClass}__item-meta`}>{typeLabel}</span>
)}
</Command.Item>
);
})}
</Command.Group>
);
};
export default SoftwarePicker;
@@ -0,0 +1,22 @@
import { RESULT_PREFIXES, isPreFilteredResult } from "./constants";
describe("isPreFilteredResult", () => {
it("returns true for every value beginning with a known RESULT_PREFIX", () => {
Object.values(RESULT_PREFIXES).forEach((prefix) => {
expect(isPreFilteredResult(`${prefix}42`)).toBe(true);
});
});
it("returns false for arbitrary strings", () => {
expect(isPreFilteredResult("dashboard home")).toBe(false);
expect(isPreFilteredResult("EXACT_MATCH dashboard")).toBe(false);
expect(isPreFilteredResult("")).toBe(false);
});
it("returns false for the singular form (typo guard)", () => {
// Catches a future picker typoing the prefix (e.g., "HOSTS_RESULT ").
expect(isPreFilteredResult("HOSTS_RESULT 1")).toBe(false);
expect(isPreFilteredResult("HOST_RESULTS 1")).toBe(false);
expect(isPreFilteredResult("REPORTS_RESULT 1")).toBe(false);
});
});
@@ -0,0 +1,19 @@
/**
* Value prefixes used in cmdk Command.Item `value` props so the parent
* dialog's filter function can pass them through unconditionally (the
* server already filtered the results). Centralized here so a typo in
* one picker can't silently fall back to cmdk's substring match.
*/
export const RESULT_PREFIXES = {
host: "HOST_RESULT ",
software: "SOFTWARE_RESULT ",
report: "REPORT_RESULT ",
policy: "POLICY_RESULT ",
} as const;
/**
* True when a cmdk value should bypass local filtering used by the
* dialog's `filter` prop.
*/
export const isPreFilteredResult = (value: string): boolean =>
Object.values(RESULT_PREFIXES).some((prefix) => value.startsWith(prefix));
@@ -0,0 +1,21 @@
import { APP_CONTEXT_NO_TEAM_ID, ITeamSummary } from "interfaces/team";
/**
* Returns the trailing fleet-context phrase used in picker empty states.
* Mirrors the convention from EmptyVulnerabilitiesTable elsewhere in the
* codebase:
* - Real team (id > 0) " in Engineering"
* - Unassigned (id === 0) " in this fleet"
* - All fleets (id === -1) "" (no suffix; context is global)
* - Undefined currentTeam "" (defensive)
*
* Used with copy like `\`No reports found${getFleetSuffix(currentTeam)}.\``.
*/
const getFleetSuffix = (currentTeam?: ITeamSummary): string => {
if (!currentTeam) return "";
if (currentTeam.id > 0) return ` in ${currentTeam.name}`;
if (currentTeam.id === APP_CONTEXT_NO_TEAM_ID) return " in this fleet";
return "";
};
export default getFleetSuffix;
@@ -0,0 +1,118 @@
import React from "react";
import { act, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "react-query";
import usePickerSearch from "./usePickerSearch";
// Probe component that exposes the hook's return values via the DOM
// so we can assert on them from tests.
interface IProbeProps<TResponse, TItem> {
search: string;
queryFn: (q: string) => Promise<TResponse>;
selectItems: (data: TResponse | undefined) => TItem[];
}
const Probe = <TResponse, TItem>({
search,
queryFn,
selectItems,
}: IProbeProps<TResponse, TItem>) => {
const { items, isLoading, debouncedQuery } = usePickerSearch<
TResponse,
TItem
>({
search,
queryKeyPrefix: ["test"],
queryFn,
selectItems,
});
return (
<div>
<span data-testid="loading">{String(isLoading)}</span>
<span data-testid="debounced">{debouncedQuery}</span>
<span data-testid="count">{items.length}</span>
</div>
);
};
const renderWithClient = (ui: React.ReactElement) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, cacheTime: 0 } },
});
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return render(ui, { wrapper });
};
describe("usePickerSearch", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("debounces the search prop before invoking queryFn", async () => {
const queryFn = jest.fn().mockResolvedValue({ items: [] });
const selectItems = (d?: { items: number[] }) => d?.items ?? [];
const { rerender } = renderWithClient(
<Probe search="a" queryFn={queryFn} selectItems={selectItems} />
);
// queryFn fires immediately with the *initial* debouncedQuery state
// (the initial useState(search.trim()) value).
await waitFor(() => expect(queryFn).toHaveBeenCalledWith("a"));
// Update search rapidly — debounce should swallow intermediate values.
rerender(<Probe search="ab" queryFn={queryFn} selectItems={selectItems} />);
rerender(
<Probe search="abc" queryFn={queryFn} selectItems={selectItems} />
);
// Before the timer fires, queryFn should still only have one call.
expect(queryFn).toHaveBeenCalledTimes(1);
// Advance past the debounce window.
await act(async () => {
jest.advanceTimersByTime(200);
});
await waitFor(() => {
expect(queryFn).toHaveBeenCalledWith("abc");
});
// Intermediate "ab" should not have been queried.
expect(queryFn).not.toHaveBeenCalledWith("ab");
});
it("trims the search input before debouncing", async () => {
const queryFn = jest.fn().mockResolvedValue({ items: [] });
const selectItems = (d?: { items: number[] }) => d?.items ?? [];
renderWithClient(
<Probe
search=" padded "
queryFn={queryFn}
selectItems={selectItems}
/>
);
await waitFor(() => expect(queryFn).toHaveBeenCalledWith("padded"));
});
it("uses selectItems to extract the displayed array from the response", async () => {
const queryFn = jest
.fn()
.mockResolvedValue({ inner: { items: [1, 2, 3] } });
const selectItems = (d?: { inner: { items: number[] } }) =>
d?.inner?.items ?? [];
const { getByTestId } = renderWithClient(
<Probe search="" queryFn={queryFn} selectItems={selectItems} />
);
await waitFor(() => {
expect(getByTestId("count").textContent).toBe("3");
});
});
});
@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import { useQuery, UseQueryOptions } from "react-query";
const DEBOUNCE_MS = 200;
interface IUsePickerSearchOptions<TResponse, TItem> {
/** Raw search input from the parent. Debounced internally. */
search: string;
/**
* React Query key prefix WITHOUT the search term e.g.,
* `["commandPaletteHosts", teamId]`. The hook appends the debounced
* query internally so the cache key stays in lockstep with the value
* passed into queryFn (using raw search here would tag fresh cache
* entries with stale data while the debounce settles).
*
* Must be an array react-query accepts a bare string as a QueryKey,
* but spreading one into the cache key would iterate its characters.
*/
queryKeyPrefix: readonly unknown[];
/** Function that fetches the response given the debounced query. */
queryFn: (debouncedQuery: string) => Promise<TResponse>;
/** Extract the displayed item array from the response. */
selectItems: (data: TResponse | undefined) => TItem[];
/** Optional overrides for react-query (rarely needed). */
queryOptions?: Omit<
UseQueryOptions<TResponse, Error>,
"queryKey" | "queryFn"
>;
}
/**
* Shared scaffolding for the command-palette pickers: a 200ms debounce on
* the raw `search` input + a `useQuery` against a server endpoint that
* pre-filters by the debounced query. Returns the extracted item array,
* the loading flag, and the resolved debounced query (used for empty-state
* copy in the consumer).
*/
const usePickerSearch = <TResponse, TItem>({
search,
queryKeyPrefix,
queryFn,
selectItems,
queryOptions,
}: IUsePickerSearchOptions<TResponse, TItem>) => {
const [debouncedQuery, setDebouncedQuery] = useState(search.trim());
useEffect(() => {
const id = window.setTimeout(() => {
setDebouncedQuery(search.trim());
}, DEBOUNCE_MS);
return () => window.clearTimeout(id);
}, [search]);
const { data, isLoading } = useQuery<TResponse, Error>(
[...queryKeyPrefix, debouncedQuery],
() => queryFn(debouncedQuery),
{
keepPreviousData: true,
staleTime: 30000,
// Pickers are short-lived UI; release cached entries 60s after the
// last consumer unmounts so a long session doesn't accumulate
// distinct (team, query) tuples indefinitely (react-query's
// default cacheTime is 5 min).
cacheTime: 60000,
...queryOptions,
}
);
return {
items: selectItems(data),
isLoading,
debouncedQuery,
};
};
export default usePickerSearch;
@@ -0,0 +1,144 @@
import paths from "router/paths";
import { ICommandItem, ICommandPaletteContext } from "../helpers";
import { IDerivedContext } from "./derivations";
const buildAutomationsItems = (
ctx: ICommandPaletteContext,
derived: IDerivedContext
): ICommandItem[] => {
const {
canAccessSettings,
canManageSoftwareAutomations,
canManagePolicyAutomations,
canWrite,
currentTeam,
hasTeamSelected,
isPremiumTier,
isPrimoMode,
withTeamId,
} = ctx;
const { isUnassigned, switchesFromUnassigned, hasTeamOrUnassigned } = derived;
return [
// Manage automations — software. Normally All-fleets-only, but in
// Primo Mode the single fleet acts as "all fleets" so the destination
// page (SoftwarePage) accepts it too.
...(canManageSoftwareAutomations &&
((!hasTeamSelected && !isUnassigned) || isPrimoMode)
? [
{
id: "manage-software-automations",
label: "Manage software automations",
group: "Automations" as const,
path: `${paths.SOFTWARE_INVENTORY}?manage_automations=1`,
keywords: ["vulnerability", "webhook", "jira", "zendesk"],
},
]
: []),
// Manage automations — activity feed (global admin only)
...(canAccessSettings
? [
{
id: "manage-activity-automations",
label: "Manage activity automations",
group: "Automations" as const,
path: `${paths.DASHBOARD}?manage_automations=1`,
keywords: ["activity feed", "webhook", "audit log"],
},
]
: []),
// Manage automations — reports (anyone who can write)
...(canWrite
? [
{
id: "manage-report-automations",
label: "Manage report automations",
group: "Automations" as const,
path: withTeamId(`${paths.MANAGE_REPORTS}?manage_automations=1`),
keywords: ["report", "logging", "destination"],
teamName: switchesFromUnassigned,
},
]
: []),
// Manage automations — policies (admins and maintainers)
...(canManagePolicyAutomations
? [
{
id: "manage-policy-automations",
label: "Manage policy automations",
group: "Automations" as const,
path: withTeamId(paths.MANAGE_POLICIES),
keywords: ["failing", "webhook", "jira", "zendesk"],
subItems: [
{
id: "manage-policy-automations-webhooks",
label: "Tickets & webhooks",
path: withTeamId(
`${paths.MANAGE_POLICIES}?manage_automations=webhooks`
),
keywords: ["jira", "zendesk", "failing"],
},
// Team-scoped policy automations (Premium-only). The
// policies page allows install_software / run_script /
// conditional_access on No team / Unassigned, so those
// three use `hasTeamOrUnassigned`. Calendar events stay
// on `hasTeamSelected` — the page disables them when
// there's no specific team.
...(isPremiumTier && hasTeamOrUnassigned
? [
{
id: "manage-policy-automations-install-software",
label: "Install software",
path: `${paths.MANAGE_POLICIES}?fleet_id=${currentTeam?.id}&manage_automations=install_software`,
keywords: ["resolve", "remediate"],
},
{
id: "manage-policy-automations-run-script",
label: "Run script",
path: `${paths.MANAGE_POLICIES}?fleet_id=${currentTeam?.id}&manage_automations=run_script`,
keywords: ["resolve", "remediate"],
},
]
: []),
...(isPremiumTier && hasTeamSelected
? [
{
id: "manage-policy-automations-calendar",
label: "Calendar events",
path: `${paths.MANAGE_POLICIES}?fleet_id=${currentTeam?.id}&manage_automations=calendar`,
keywords: [
"reserve time",
"maintenance window",
"google calendar",
],
},
]
: []),
...(isPremiumTier && hasTeamOrUnassigned
? [
{
id: "manage-policy-automations-conditional-access",
label: "Conditional access",
path: `${paths.MANAGE_POLICIES}?fleet_id=${currentTeam?.id}&manage_automations=conditional_access`,
keywords: [
"sso",
"okta",
"entra",
"intune",
"zero trust",
],
},
]
: []),
],
},
]
: []),
];
};
export default buildAutomationsItems;
@@ -0,0 +1,445 @@
import paths from "router/paths";
import { ICommandItem, ICommandPaletteContext } from "../helpers";
import { IDerivedContext } from "./derivations";
const buildCommandsItems = (
ctx: ICommandPaletteContext,
derived: IDerivedContext
): ICommandItem[] => {
const {
search,
canAccessSettings,
canRunLiveReport,
canWrite,
canEditCustomVariable,
canAddSoftware,
isTechnician,
isPremiumTier,
isPrimoMode,
isDarkMode,
withTeamId,
onToggleDarkMode,
onViewHost,
onViewSoftware,
onViewSoftwareLibrary,
onViewReport,
onViewPolicy,
} = ctx;
const {
hasTeamOrUnassigned,
isGitOpsMode,
switchesFromUnassigned,
teamRequiredDestination,
defaultDestination,
} = derived;
return [
// Create new pack — companion to the "Packs" page in pages.ts. Shares
// the same search-regex condition. Kept here so the Commands group
// stays self-contained.
...(/packs|create new pack|add new pack/.test(search.toLowerCase())
? [
{
id: "new-pack",
label: "Create new pack",
group: "Commands" as const,
path: paths.NEW_PACK,
keywords: ["packs", "add new pack", "create new pack"],
},
]
: []),
// View commands — open sub-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.
{
id: "view-host",
label: "View host",
group: "Commands" as const,
keywords: [
"host",
"device",
"find host",
"open host",
"host details",
"endpoint",
"machine",
"search host",
"search hosts",
],
onAction: onViewHost,
opensSubPage: true,
},
{
id: "view-software",
label: "View software inventory",
group: "Commands" as const,
keywords: [
"software",
"app",
"application",
"package",
"find software",
"open software",
"title",
"version",
"search software",
"search software inventory",
"inventory",
],
onAction: onViewSoftware,
opensSubPage: true,
},
// View software library — Premium-only and hidden on "All fleets" since
// libraries are per-fleet.
...(isPremiumTier && hasTeamOrUnassigned
? [
{
id: "view-software-library",
label: "View software library",
group: "Commands" as const,
keywords: [
"library",
"installable",
"install",
"available",
"package",
"vpp",
"fma",
"fleet-maintained",
"search software library",
"search library",
],
onAction: onViewSoftwareLibrary,
opensSubPage: true,
},
]
: []),
{
id: "view-report",
label: "View report",
group: "Commands" as const,
keywords: [
"report",
"query",
"queries",
"sql",
"saved query",
"find report",
"open report",
"search report",
"search reports",
],
onAction: onViewReport,
opensSubPage: true,
},
{
id: "view-policy",
label: "View policy",
group: "Commands" as const,
keywords: [
"policy",
"compliance",
"failing",
"device health",
"find policy",
"open policy",
"search policy",
"search policies",
],
onAction: onViewPolicy,
opensSubPage: true,
},
// Actions — users who can write
...(canWrite
? [
{
id: "add-hosts",
label: "Add hosts",
group: "Commands" as const,
path: withTeamId(`${paths.MANAGE_HOSTS}?add_hosts=1`),
keywords: ["enroll", "install", "fleetd", "device"],
teamName: teamRequiredDestination,
},
{
id: "add-report",
label: "Add report",
group: "Commands" as const,
path: withTeamId(paths.NEW_REPORT),
keywords: ["create report", "new report", "sql"],
teamName: defaultDestination,
},
{
id: "add-policy",
label: "Add policy",
group: "Commands" as const,
path: withTeamId(paths.NEW_POLICY),
keywords: [
"create policy",
"new policy",
"compliance",
"device health",
],
},
// Software add actions require Premium + a team or unassigned
// (not "All fleets"). Each destination page renders a
// <PremiumFeatureMessage /> in Free. Also gated on
// `canAddSoftware` which mirrors SoftwarePage's "Add software"
// button — global admin/maintainer or admin/maintainer of the
// CURRENT team (not any team). Excludes technicians and
// cross-team admins/maintainers who would otherwise pass the
// broad `canWrite` check above.
...(isPremiumTier && hasTeamOrUnassigned && canAddSoftware
? [
{
id: "add-fleet-maintained-app",
label: "Add Fleet-maintained app",
group: "Commands" as const,
path: withTeamId(paths.SOFTWARE_ADD_FLEET_MAINTAINED),
keywords: [
"install",
"software",
"managed app",
"fma",
"add app",
],
},
{
id: "add-vpp-app",
label: "Add VPP app",
group: "Commands" as const,
path: withTeamId(
`${paths.SOFTWARE_ADD_APP_STORE}?platform=apple`
),
keywords: [
"app store",
"volume purchase",
"apple",
"ios",
"ipados",
"macos",
"add app",
],
},
{
id: "add-android-app-store-app",
label: "Add Android app store app",
group: "Commands" as const,
path: withTeamId(
`${paths.SOFTWARE_ADD_APP_STORE}?platform=android`
),
keywords: ["google play", "android", "play store", "add app"],
},
{
id: "add-custom-package",
label: "Add custom package",
group: "Commands" as const,
path: withTeamId(paths.SOFTWARE_ADD_PACKAGE),
keywords: [
"install",
"upload",
"software",
"add package",
"pkg",
"ipa",
"msi",
"exe",
"ps1",
"deb",
"rpm",
"tar.gz",
"tarballs",
"sh",
],
},
]
: []),
// Script and variable actions require a team or unassigned
// (not "All fleets"). Scripts also hide for technicians —
// ScriptLibrary.tsx disables the page button for them.
...(hasTeamOrUnassigned && !isTechnician
? [
{
id: "add-script",
label: "Add script",
group: "Commands" as const,
// ScriptLibrary opens its add-script modal on
// `?add_script=1`, mirroring the Variables page pattern.
path: withTeamId(
`${paths.CONTROLS_SCRIPTS_LIBRARY}?add_script=1`
),
keywords: [
"upload script",
"shell",
"sh",
"ps1",
"create script",
],
},
// Custom Variables: page-side `canEdit` is global admin
// or global maintainer only — team admins/maintainers and
// technicians (all `canWrite`) can't actually create one,
// so hide the palette entry rather than route them to a
// read-only page. Not Premium-gated.
...(canEditCustomVariable
? [
{
id: "add-custom-variable",
label: "Add custom variable",
group: "Commands" as const,
path: withTeamId(
`${paths.CONTROLS_VARIABLES}?add_variable=1`
),
keywords: [
"secret",
"scripts",
"profiles",
"add variable",
"create variable",
],
},
]
: []),
]
: []),
{
id: "manage-enroll-secrets",
label: "Manage enroll secrets",
group: "Commands" as const,
path: withTeamId(`${paths.MANAGE_HOSTS}?manage_enroll_secrets=1`),
keywords: ["enrollment", "token", "fleetd", "enroll secret"],
teamName: teamRequiredDestination,
},
]
: []),
// Run live report — Observer+ users can also run live queries.
// Placed here so it sits adjacent to Run live policy below.
...(canRunLiveReport
? [
{
id: "run-live-report",
label: "Run live report",
group: "Commands" as const,
path: withTeamId(paths.NEW_REPORT),
keywords: [
"osquery",
"sql",
"live",
"ad hoc",
"query",
"run report",
],
teamName: switchesFromUnassigned,
},
]
: []),
// Actions (continued) — users who can write
...(canWrite
? [
{
id: "run-live-policy",
label: "Run live policy",
group: "Commands" as const,
path: withTeamId(paths.NEW_POLICY),
keywords: ["check", "compliance", "live", "ad hoc", "run policy"],
},
{
id: "add-label",
label: "Add label",
group: "Commands" as const,
path: paths.NEW_LABEL,
keywords: [
"create label",
"new label",
"group hosts",
"filter",
"dynamic",
"manual",
],
},
...(canAccessSettings
? [
{
id: "add-user",
label: "Add user",
group: "Commands" as const,
path: paths.ADMIN_USERS_NEW_HUMAN,
keywords: [
"new user",
"create user",
"invite",
"account",
"human user",
],
},
{
id: "add-api-only-user",
label: "Add API-only user",
group: "Commands" as const,
path: paths.ADMIN_USERS_NEW_API,
keywords: [
"api user",
"api only user",
"service account",
"token",
"create api user",
"create api only user",
"gitops user",
"add user",
"create user",
],
},
// Create fleet — Premium-only, hidden in Primo Mode, and
// hidden in GitOps Mode (ManageFleetsPage disables the
// primary action in all three states).
...(isPremiumTier && !isPrimoMode && !isGitOpsMode
? [
{
id: "create-fleet",
label: "Create fleet",
group: "Commands" as const,
path: `${paths.ADMIN_FLEETS}?create_fleet=1`,
keywords: [
"new fleet",
"add fleet",
"team",
"create team",
"add team",
"new team",
],
},
]
: []),
]
: []),
]
: []),
// Theme toggle and Sign out — always available. Theme is a per-user
// UI preference, not a write against Fleet data (setThemeMode is
// exposed to every signed-in user via My Account → Theme), so it
// sits outside the canWrite gate alongside Sign out.
{
id: "toggle-dark-mode",
// isDarkMode comes through as reactive state from the parent
// so the label re-renders when the theme flips externally.
label: isDarkMode ? "Switch to light mode" : "Switch to dark mode",
group: "Commands" as const,
keywords: ["dark mode", "light mode", "theme", "toggle"],
onAction: onToggleDarkMode,
},
{
id: "sign-out",
label: "Sign out",
path: paths.LOGOUT,
group: "Commands" as const,
keywords: ["logout", "log out", "sign out"],
},
];
};
export default buildCommandsItems;
@@ -0,0 +1,171 @@
import paths from "router/paths";
import { ICommandItem, ICommandPaletteContext } from "../helpers";
import { IDerivedContext } from "./derivations";
const buildControlsItems = (
ctx: ICommandPaletteContext,
derived: IDerivedContext
): ICommandItem[] => {
const { canAccessControls, isPremiumTier, isTechnician, withTeamId } = ctx;
const { hasTeamOrUnassigned } = derived;
// Controls pages don't support "All fleets" (includeAllTeams: false),
// so only show when a team or unassigned is selected. Also gated by
// canAccessControls (maintainers, admins, technicians).
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 settings sub-pages
{
id: "controls-os-settings",
label: "OS settings",
group: "Controls" as const,
path: withTeamId(paths.CONTROLS_OS_SETTINGS),
keywords: ["enforce", "remotely", "profiles"],
subItems: [
// Disk encryption is Premium-only.
...(isPremiumTier
? [
{
id: "controls-disk-encryption",
label: "Disk encryption",
path: withTeamId(paths.CONTROLS_DISK_ENCRYPTION),
keywords: ["filevault", "bitlocker", "recovery key"],
},
]
: []),
{
id: "controls-custom-settings",
label: "Configuration profiles",
path: withTeamId(paths.CONTROLS_CUSTOM_SETTINGS),
keywords: [
"custom profiles",
"mobileconfig",
"deploy",
"ddm",
"windows csp",
],
},
// Certificates and Passwords — Premium-only, and not
// available to technicians.
...(isPremiumTier && !isTechnician
? [
{
id: "controls-certificates",
label: "Certificates",
path: withTeamId(paths.CONTROLS_CERTIFICATES),
keywords: [
"scep",
"est",
"pki",
"digicert",
"ndes",
"certificate authority",
"ca",
],
},
{
id: "controls-passwords",
label: "Passwords",
path: withTeamId(paths.CONTROLS_PASSWORDS),
keywords: ["rotation", "recovery", "macos"],
},
]
: []),
],
},
// Setup experience sub-pages — Premium-only.
...(isPremiumTier
? [
{
id: "controls-setup-experience",
label: "Setup experience",
group: "Controls" as const,
path: withTeamId(paths.CONTROLS_SETUP_EXPERIENCE),
keywords: ["customize", "end user", "enrollment"],
subItems: [
{
id: "controls-users",
label: "Users",
path: withTeamId(paths.CONTROLS_USERS),
keywords: ["idp", "login", "sso"],
},
{
id: "controls-bootstrap-package",
label: "Bootstrap package",
path: withTeamId(paths.CONTROLS_BOOTSTRAP_PACKAGE),
keywords: ["pkg", "deploy"],
},
{
id: "controls-install-software",
label: "Install software",
path: withTeamId(paths.CONTROLS_INSTALL_SOFTWARE("macos")),
keywords: ["automatic install"],
},
{
id: "controls-run-script",
label: "Run script",
path: withTeamId(paths.CONTROLS_RUN_SCRIPT),
keywords: ["shell", "post-enrollment"],
},
{
id: "controls-setup-assistant",
label: "Setup Assistant",
path: withTeamId(paths.CONTROLS_SETUP_ASSISTANT),
keywords: ["apple", "dep", "ade"],
},
],
},
]
: []),
// Scripts
{
id: "controls-scripts",
label: "Scripts",
group: "Controls" as const,
path: withTeamId(paths.CONTROLS_SCRIPTS),
keywords: ["remediate", "macos", "windows", "linux"],
subItems: [
{
id: "controls-scripts-library",
label: "Script library",
path: withTeamId(paths.CONTROLS_SCRIPTS_LIBRARY),
keywords: ["saved", "uploaded", "manage"],
},
{
id: "controls-scripts-batch-progress",
label: "Script batch progress",
path: withTeamId(paths.CONTROLS_SCRIPTS_BATCH_PROGRESS),
keywords: ["status", "running", "results"],
},
],
},
// Variables
{
id: "controls-variables",
label: "Variables",
group: "Controls" as const,
path: withTeamId(paths.CONTROLS_VARIABLES),
keywords: ["custom", "scripts", "profiles"],
},
];
};
export default buildControlsItems;
@@ -0,0 +1,95 @@
import { ICommandPaletteContext } from "../helpers";
/**
* Values derived once from the raw context and shared across all group
* builders. Computed in `deriveContext` and passed as the second argument
* to each builder so the derivation logic lives in exactly one place.
*/
export interface IDerivedContext {
/** Apple Business Manager configured at the org level. */
isAbmConfigured: boolean;
/** GitOps mode active — disables Create-fleet etc. */
isGitOpsMode: boolean;
/** currentTeam is the "No team" / Unassigned sentinel (id 0). */
isUnassigned: boolean;
/** A specific team OR unassigned is selected (not "All fleets"). */
hasTeamOrUnassigned: boolean;
/** Chip label when navigating from Unassigned to an "All fleets" page. */
switchesFromUnassigned: string | undefined;
/**
* Chip label when navigating from "All fleets" to a page that requires a
* specific team (Controls etc.). Returns the default fleet name.
*/
switchesFromAllFleets: string | undefined;
/**
* Chip label for team-required commands (add-hosts, manage-enroll-secrets)
* set to "Unassigned" only when on "All fleets" and would switch context.
*/
teamRequiredDestination: string | undefined;
/**
* Chip label for default-context commands (add-report, software automations)
* set to "All fleets" only when on Unassigned and would switch context.
*/
defaultDestination: string | undefined;
}
/** Run once per buildPaletteItems call; passed to every group builder. */
export const deriveContext = (ctx: ICommandPaletteContext): IDerivedContext => {
const {
config,
currentTeam,
availableTeams,
hasTeamSelected,
isPrimoMode,
} = ctx;
const isAbmConfigured = config?.mdm?.apple_bm_enabled_and_configured ?? false;
// GitOps mode disables write actions in the UI; mirrors the predicate
// ManageFleetsPage uses to disable its Create fleet button.
const isGitOpsMode = !!(
config?.gitops?.gitops_mode_enabled && config?.gitops?.repository_url
);
const isUnassigned = currentTeam?.id === 0;
const hasTeamOrUnassigned = !!hasTeamSelected || isUnassigned;
// In Primo Mode the user perceives a single-fleet install, so the
// concept of "switching fleet context" doesn't apply. All destination
// chips collapse to undefined.
const switchesFromUnassigned =
!isPrimoMode && isUnassigned ? "All fleets" : undefined;
const getDefaultTeamName = (): string | undefined => {
if (isPrimoMode) return undefined;
if (hasTeamOrUnassigned) return undefined;
const realFleets = availableTeams?.filter((t) => t.id > 0) ?? [];
if (!realFleets.length) return undefined;
const workstations = realFleets.find((t) => {
const lower = t.name.toLowerCase();
return lower === "workstations" || lower === "\u{1F4BB} workstations";
});
return (workstations ?? realFleets.sort((a, b) => a.id - b.id)[0])?.name;
};
const switchesFromAllFleets = getDefaultTeamName();
const teamRequiredDestination =
!isPrimoMode && !hasTeamSelected && !isUnassigned
? "Unassigned"
: undefined;
const defaultDestination =
!isPrimoMode && isUnassigned ? "All fleets" : undefined;
return {
isAbmConfigured,
isGitOpsMode,
isUnassigned,
hasTeamOrUnassigned,
switchesFromUnassigned,
switchesFromAllFleets,
teamRequiredDestination,
defaultDestination,
};
};
@@ -0,0 +1,117 @@
import paths from "router/paths";
import { ICommandItem, ICommandPaletteContext } from "../helpers";
import { IDerivedContext } from "./derivations";
const buildMdmItems = (
ctx: ICommandPaletteContext,
derived: IDerivedContext
): ICommandItem[] => {
const {
canAccessSettings,
isPremiumTier,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isAndroidMdmEnabledAndConfigured,
isVppEnabled,
} = ctx;
const { isAbmConfigured } = derived;
// MDM section is global-admin only.
if (!canAccessSettings) return [];
return [
// Apple MDM — turn on or edit
...(!isMacMdmEnabledAndConfigured
? [
{
id: "turn-on-apple-mdm",
label: "Turn on Apple (macOS, iOS, iPadOS) MDM",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_MDM_APPLE,
keywords: ["enable", "apns", "dep"],
},
]
: [
{
id: "edit-apple-mdm",
label: "Edit Apple (macOS, iOS, iPadOS) MDM",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_MDM_APPLE,
keywords: ["apns", "certificate", "renew"],
},
// ABM 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)",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_APPLE_BUSINESS_MANAGER,
keywords: ["dep", "automated enrollment", "apple"],
},
{
id: isVppEnabled ? "edit-vpp" : "add-vpp",
label: isVppEnabled
? "Edit Volume Purchasing Program (VPP)"
: "Add Volume Purchasing Program (VPP)",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_VPP,
keywords: ["app store", "apple", "token"],
},
]
: []),
]),
// Windows MDM — turn on or edit
...(!isWindowsMdmEnabledAndConfigured
? [
{
id: "turn-on-windows-mdm",
label: "Turn on Windows MDM",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_MDM_WINDOWS,
keywords: ["enable", "microsoft"],
},
]
: [
{
id: "edit-windows-mdm",
label: "Edit Windows MDM",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_MDM_WINDOWS,
keywords: ["microsoft", "enrollment"],
},
{
id: "windows-automatic-enrollment",
label: "Windows automatic enrollment (Entra)",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS,
keywords: ["entra", "azure ad", "microsoft"],
},
]),
// Android MDM — turn on or edit
...(!isAndroidMdmEnabledAndConfigured
? [
{
id: "turn-on-android-mdm",
label: "Turn on Android MDM",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_MDM_ANDROID,
keywords: ["enable", "google", "enterprise"],
},
]
: [
{
id: "edit-android-mdm",
label: "Edit Android MDM",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_MDM_ANDROID,
keywords: ["google", "enterprise"],
},
]),
];
};
export default buildMdmItems;
@@ -0,0 +1,142 @@
import paths from "router/paths";
import { ICommandItem, ICommandPaletteContext } from "../helpers";
import { IDerivedContext } from "./derivations";
const buildPagesItems = (
ctx: ICommandPaletteContext,
derived: IDerivedContext
): ICommandItem[] => {
const { search, canAccessControls, canAccessSettings, withTeamId } = ctx;
const {
hasTeamOrUnassigned,
switchesFromUnassigned,
switchesFromAllFleets,
} = derived;
return [
{
id: "dashboard",
label: "Dashboard",
group: "Pages" as const,
path: withTeamId(paths.DASHBOARD),
teamName: switchesFromUnassigned,
keywords: ["home", "hosts", "activity", "platform"],
},
{
id: "hosts",
label: "Hosts",
group: "Pages" as const,
path: withTeamId(paths.MANAGE_HOSTS),
keywords: [
"devices",
"hostname",
"serial number",
"manage",
"endpoints",
"machines",
"computers",
],
},
...(canAccessControls && hasTeamOrUnassigned
? [
{
id: "controls-page",
label: "Controls",
group: "Pages" as const,
path: withTeamId(paths.CONTROLS),
keywords: ["mdm", "os settings", "os updates"],
teamName: switchesFromAllFleets,
},
]
: []),
{
id: "software-page",
label: "Software",
group: "Pages" as const,
path: withTeamId(paths.SOFTWARE_INVENTORY),
keywords: ["installed", "inventory", "titles", "library", "managed"],
},
{
id: "reports",
label: "Reports",
group: "Pages" as const,
path: withTeamId(paths.MANAGE_REPORTS),
keywords: [
"report",
"sql",
"gather data",
"live query",
// Legacy: "Queries" was renamed to "Reports" — users will type
// the old term for a long time.
"queries",
"query",
"saved queries",
],
teamName: switchesFromUnassigned,
},
{
id: "policies",
label: "Policies",
group: "Pages" as const,
path: withTeamId(paths.MANAGE_POLICIES),
keywords: [
"compliance",
"failing",
"device health",
"yara",
"osquery",
"sql",
],
},
...(canAccessSettings
? [
{
id: "settings-page",
label: "Settings",
group: "Pages" as const,
path: paths.ADMIN_SETTINGS,
keywords: ["admin", "organization", "integrations"],
},
]
: []),
{
id: "labels",
label: "Labels",
group: "Pages" as const,
path: paths.MANAGE_LABELS,
keywords: ["group hosts", "filter", "dynamic", "manual"],
},
// "Users" lives in the Settings group only — having it in Pages too
// surfaced two items with identical destinations.
{
id: "my-account",
label: "My account",
group: "Pages" as const,
path: paths.ACCOUNT,
keywords: [
"profile",
"password",
"api token",
"settings",
"change password",
],
},
// Packs page — only visible when searching for "packs" or similar.
// The companion "Create new pack" item lives in commands.ts.
...(/packs|create new pack|add new pack/.test(search.toLowerCase())
? [
{
id: "packs",
label: "Packs",
group: "Pages" as const,
path: paths.MANAGE_PACKS,
keywords: ["packs", "legacy", "scheduled queries"],
},
]
: []),
];
};
export default buildPagesItems;
@@ -0,0 +1,240 @@
import paths from "router/paths";
import { ICommandItem, ICommandPaletteContext } from "../helpers";
const buildSettingsItems = (ctx: ICommandPaletteContext): ICommandItem[] => {
const { canAccessSettings, isPremiumTier, isPrimoMode } = ctx;
// Settings — global admins only
if (!canAccessSettings) return [];
return [
// Organization settings
{
id: "settings-organization",
label: "Organization settings",
group: "Settings" as const,
path: paths.ADMIN_ORGANIZATION,
keywords: ["admin", "organization"],
subItems: [
{
id: "settings-org-info",
label: "Organization info",
path: paths.ADMIN_ORGANIZATION_INFO,
keywords: ["name", "logo", "branding", "support url"],
},
{
id: "settings-org-webaddress",
label: "Fleet web address",
path: paths.ADMIN_ORGANIZATION_WEBADDRESS,
keywords: ["url", "server address"],
},
{
id: "settings-org-smtp",
label: "SMTP options",
path: paths.ADMIN_ORGANIZATION_SMTP,
keywords: ["email", "sender", "password reset"],
},
{
id: "settings-org-agents",
label: "Agent options",
path: paths.ADMIN_ORGANIZATION_AGENTS,
keywords: [
"osquery",
"fleetd",
"orbit",
"flags",
"global config",
"command line flags",
],
},
{
id: "settings-org-statistics",
label: "Usage statistics",
path: paths.ADMIN_ORGANIZATION_STATISTICS,
keywords: ["telemetry", "anonymous"],
},
{
id: "settings-org-fleet-desktop",
label: "Fleet Desktop",
path: paths.ADMIN_ORGANIZATION_FLEET_DESKTOP,
keywords: [
"tray icon",
"transparency",
"end user",
"browser host",
"custom proxy",
],
},
{
id: "settings-org-advanced",
label: "Advanced options",
path: paths.ADMIN_ORGANIZATION_ADVANCED,
keywords: [
"live report",
"host expiry",
"usage statistics",
"sso user url",
"sso",
"apple mdm server url",
"verify ssl certs",
"starttls",
"host expiry",
"generative ai features",
"hardware attestation",
],
},
],
},
// Integrations
{
id: "settings-integrations",
label: "Integrations",
group: "Settings" as const,
path: paths.ADMIN_INTEGRATIONS,
keywords: ["mdm", "jira", "zendesk", "sso", "calendar"],
subItems: [
{
id: "settings-int-ticket-destinations",
label: "Ticket destinations",
path: paths.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS,
keywords: ["jira", "zendesk", "tickets"],
},
{
id: "settings-int-mdm",
label: "MDM",
path: paths.ADMIN_INTEGRATIONS_MDM,
keywords: [
"apple",
"windows",
"android",
"device management",
"apple business",
"vpp",
"entra",
],
},
// Calendars and Change management are Premium-only.
...(isPremiumTier
? [
{
id: "settings-int-calendars",
label: "Calendars",
path: paths.ADMIN_INTEGRATIONS_CALENDARS,
keywords: [
"google calendar api",
"google workspace",
"service account",
"events",
],
},
{
id: "settings-int-change-management",
label: "Change management",
path: paths.ADMIN_INTEGRATIONS_CHANGE_MANAGEMENT,
keywords: ["workflow", "gitops mode"],
},
]
: []),
{
id: "settings-int-sso-fleet-users",
label: "Single sign-on (SSO) for Fleet users",
path: paths.ADMIN_INTEGRATIONS_SSO_FLEET_USERS,
keywords: ["saml", "idp", "admin", "login"],
},
{
id: "settings-int-sso-end-users",
label: "Single sign-on (SSO) for end users",
path: paths.ADMIN_INTEGRATIONS_SSO_END_USERS,
keywords: ["saml", "idp", "device user", "login"],
},
// Certificate authorities pages are Premium-only.
...(isPremiumTier
? [
{
id: "settings-int-certificate-authorities",
label: "Certificate authorities",
path: paths.ADMIN_INTEGRATIONS_CERTIFICATE_AUTHORITIES,
keywords: [
"scep",
"est",
"digicert",
"ndes",
"smallstep",
"scep",
],
},
{
id: "add-certificate-authority",
label: "Add certificate authority",
path: paths.ADMIN_INTEGRATIONS_CERTIFICATE_AUTHORITIES,
keywords: [
"scep",
"est",
"digicert",
"ndes",
"smallstep",
"pki",
],
},
]
: []),
{
id: "settings-int-identity-provider",
label: "Identity provider (IdP)",
path: paths.ADMIN_INTEGRATIONS_IDENTITY_PROVIDER,
keywords: ["okta", "entra", "azure ad"],
},
{
id: "settings-int-host-status-webhook",
label: "Host status webhook",
path: paths.ADMIN_INTEGRATIONS_HOST_STATUS_WEBHOOK,
keywords: ["offline", "missing hosts", "notification"],
},
// Conditional access is Premium-only.
...(isPremiumTier
? [
{
id: "settings-int-conditional-access",
label: "Conditional access",
path: paths.ADMIN_INTEGRATIONS_CONDITIONAL_ACCESS,
keywords: ["okta", "entra", "intune", "zero trust"],
},
]
: []),
],
},
// Users and Fleets
{
id: "settings-users",
label: "Users",
group: "Settings" as const,
path: paths.ADMIN_USERS,
keywords: ["accounts", "admins", "invite"],
},
// Fleets settings tab — Premium-only, and hidden in Primo Mode
// (single-fleet installs don't expose fleet management).
...(isPremiumTier && !isPrimoMode
? [
{
id: "settings-fleets",
label: "Fleets",
group: "Settings" as const,
path: paths.ADMIN_FLEETS,
keywords: [
"teams",
"groups",
"add fleet",
"create fleet",
"edit fleet",
"delete fleet",
],
},
]
: []),
];
};
export default buildSettingsItems;
@@ -0,0 +1,79 @@
import paths from "router/paths";
import { ICommandItem, ICommandPaletteContext } from "../helpers";
import { IDerivedContext } from "./derivations";
const buildSoftwareItems = (
ctx: ICommandPaletteContext,
derived: IDerivedContext
): ICommandItem[] => {
const { isPremiumTier, withTeamId } = ctx;
const { hasTeamOrUnassigned } = derived;
return [
{
id: "software",
label: "Software inventory",
group: "Software" as const,
path: withTeamId(paths.SOFTWARE_INVENTORY),
keywords: ["installed", "inventory", "software titles", "detected"],
subItems: [
{
id: "software-versions",
label: "Software versions",
path: withTeamId(paths.SOFTWARE_VERSIONS),
keywords: ["versions", "installed"],
},
{
id: "software-vulnerable",
label: "Vulnerable software",
path: withTeamId(`${paths.SOFTWARE_INVENTORY}?vulnerable=true`),
keywords: ["cve", "exploited", "security"],
},
],
},
{
id: "software-os",
label: "Operating systems",
group: "Software" as const,
path: withTeamId(paths.SOFTWARE_OS),
keywords: [
"os versions",
"macos",
"windows",
"linux",
"ios",
"ipados",
"android",
"chrome",
],
},
{
id: "software-vulnerabilities",
label: "Vulnerabilities",
group: "Software" as const,
path: withTeamId(paths.SOFTWARE_VULNERABILITIES),
keywords: ["cve", "cvss", "exploit", "vulnerable software"],
},
// Library is available for any team including unassigned, but not "All fleets"
...(isPremiumTier && hasTeamOrUnassigned
? [
{
id: "software-library",
label: "Software library",
group: "Software" as const,
path: withTeamId(paths.SOFTWARE_LIBRARY),
keywords: [
"managed",
"installable",
"packages",
"self-service",
"library",
],
},
]
: []),
];
};
export default buildSoftwareItems;
@@ -0,0 +1,657 @@
import createMockConfig from "__mocks__/configMock";
import { buildPaletteItems, GROUPS, ICommandPaletteContext } from "./helpers";
const BASE_CONTEXT: ICommandPaletteContext = {
search: "",
currentTeam: undefined,
config: createMockConfig(),
canAccessControls: true,
canWrite: true,
canRunLiveReport: true,
canAccessSettings: true,
canManagePolicyAutomations: true,
canManageSoftwareAutomations: true,
canEditCustomVariable: true,
canAddSoftware: true,
isTechnician: false,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
isWindowsMdmEnabledAndConfigured: true,
isAndroidMdmEnabledAndConfigured: false,
isVppEnabled: false,
hasTeamSelected: false,
withTeamId: (path: string) => path,
onToggleDarkMode: jest.fn(),
onViewHost: jest.fn(),
onViewSoftware: jest.fn(),
onViewSoftwareLibrary: jest.fn(),
onViewReport: jest.fn(),
onViewPolicy: jest.fn(),
};
describe("CommandPalette helpers", () => {
describe("GROUPS", () => {
it("contains all expected groups in order", () => {
expect(GROUPS).toEqual([
"Pages",
"Controls",
"Software",
"Settings",
"MDM",
"Automations",
"Commands",
]);
});
});
describe("buildPaletteItems", () => {
it("returns items for a global admin with a team selected", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: true,
currentTeam: { id: 1, name: "Engineering" },
});
expect(items.length).toBeGreaterThan(0);
const ids = items.map((i) => i.id);
expect(ids).toContain("dashboard");
expect(ids).toContain("hosts");
expect(ids).toContain("controls-page");
expect(ids).toContain("software-page");
expect(ids).toContain("reports");
expect(ids).toContain("policies");
expect(ids).toContain("settings-page");
});
it("hides controls on All fleets", () => {
const items = buildPaletteItems(BASE_CONTEXT);
const ids = items.map((i) => i.id);
expect(ids).not.toContain("controls-page");
expect(ids).not.toContain("controls-os-updates");
expect(ids).not.toContain("controls-os-settings");
});
it("excludes controls for observers", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
canAccessControls: false,
canWrite: false,
canAccessSettings: false,
canManagePolicyAutomations: false,
canManageSoftwareAutomations: false,
});
const ids = items.map((i) => i.id);
expect(ids).not.toContain("controls-page");
expect(ids).not.toContain("controls-os-updates");
expect(ids).not.toContain("settings-page");
expect(ids).not.toContain("add-hosts");
});
it("keeps toggle-dark-mode and sign-out available for observers (no write)", () => {
// Theme is a per-user UI preference exposed via My Account → Theme
// for every signed-in user, so the palette item must survive a
// canWrite=false context. Sign out is the other always-on item.
const items = buildPaletteItems({
...BASE_CONTEXT,
canAccessControls: false,
canWrite: false,
canAccessSettings: false,
canManagePolicyAutomations: false,
canManageSoftwareAutomations: false,
});
const ids = items.map((i) => i.id);
expect(ids).toContain("toggle-dark-mode");
expect(ids).toContain("sign-out");
});
it("excludes settings for non-global-admins", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
canAccessSettings: false,
canManageSoftwareAutomations: false,
});
const ids = items.map((i) => i.id);
expect(ids).not.toContain("settings-page");
expect(ids).not.toContain("settings-organization");
expect(ids).not.toContain("settings-integrations");
expect(ids).not.toContain("manage-software-automations");
});
it("shows packs only when searching for 'packs'", () => {
const itemsNoSearch = buildPaletteItems(BASE_CONTEXT);
expect(itemsNoSearch.map((i) => i.id)).not.toContain("packs");
const itemsWithSearch = buildPaletteItems({
...BASE_CONTEXT,
search: "packs",
});
const ids = itemsWithSearch.map((i) => i.id);
expect(ids).toContain("packs");
expect(ids).toContain("new-pack");
});
it("does not show packs when searching for 'package'", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
search: "package",
});
expect(items.map((i) => i.id)).not.toContain("packs");
});
it("omits the teamName chip when destination matches current context", () => {
// On Engineering, every action either stays on Engineering or goes
// there — no chip should render.
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: true,
currentTeam: { id: 1, name: "Engineering" },
});
const addHosts = items.find((i) => i.id === "add-hosts");
expect(addHosts?.teamName).toBeUndefined();
const addReport = items.find((i) => i.id === "add-report");
expect(addReport?.teamName).toBeUndefined();
});
it("shows 'Unassigned' on add-hosts and manage-enroll-secrets when on All fleets", () => {
const items = buildPaletteItems(BASE_CONTEXT);
const addHosts = items.find((i) => i.id === "add-hosts");
expect(addHosts?.teamName).toBe("Unassigned");
const enrollSecrets = items.find((i) => i.id === "manage-enroll-secrets");
expect(enrollSecrets?.teamName).toBe("Unassigned");
});
it("omits the 'All fleets' chip on default-context actions when already on All fleets", () => {
// add-report stays on All fleets when invoked from All fleets — no
// switch, no chip.
const items = buildPaletteItems(BASE_CONTEXT);
const addReport = items.find((i) => i.id === "add-report");
expect(addReport?.teamName).toBeUndefined();
});
it("shows 'All fleets' on default-context actions when on Unassigned", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
});
const addReport = items.find((i) => i.id === "add-report");
expect(addReport?.teamName).toBe("All fleets");
});
it("omits the 'Unassigned' chip on add-hosts when already on Unassigned", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
});
const addHosts = items.find((i) => i.id === "add-hosts");
expect(addHosts?.teamName).toBeUndefined();
});
it("shows 'Turn on' MDM when not configured", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
isMacMdmEnabledAndConfigured: false,
isWindowsMdmEnabledAndConfigured: false,
isAndroidMdmEnabledAndConfigured: false,
});
const ids = items.map((i) => i.id);
expect(ids).toContain("turn-on-apple-mdm");
expect(ids).toContain("turn-on-windows-mdm");
expect(ids).toContain("turn-on-android-mdm");
expect(ids).not.toContain("edit-apple-mdm");
expect(ids).not.toContain("edit-windows-mdm");
});
it("shows 'Edit' MDM when configured", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
isMacMdmEnabledAndConfigured: true,
isWindowsMdmEnabledAndConfigured: true,
isAndroidMdmEnabledAndConfigured: true,
});
const ids = items.map((i) => i.id);
expect(ids).toContain("edit-apple-mdm");
expect(ids).toContain("edit-windows-mdm");
expect(ids).toContain("edit-android-mdm");
expect(ids).not.toContain("turn-on-apple-mdm");
});
it("shows 'Add ABM' when Apple MDM on but ABM not configured", () => {
const configNoAbm = createMockConfig();
configNoAbm.mdm = {
...configNoAbm.mdm,
apple_bm_enabled_and_configured: false,
};
const items = buildPaletteItems({
...BASE_CONTEXT,
config: configNoAbm,
});
const abm = items.find((i) => i.id === "add-abm");
expect(abm).toBeDefined();
expect(abm?.label).toContain("Add");
});
it("shows 'Edit ABM' when ABM is configured", () => {
const items = buildPaletteItems(BASE_CONTEXT);
const abm = items.find((i) => i.id === "edit-abm");
expect(abm).toBeDefined();
expect(abm?.label).toContain("Edit");
});
it("shows 'Edit VPP' when VPP is enabled", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
isVppEnabled: true,
});
const vpp = items.find((i) => i.id === "edit-vpp");
expect(vpp).toBeDefined();
expect(vpp?.label).toContain("Edit");
});
it("shows team-scoped policy automations when premium and team selected", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: true,
currentTeam: { id: 1, name: "Engineering" },
});
const policyAutomations = items.find(
(i) => i.id === "manage-policy-automations"
);
expect(policyAutomations?.subItems?.length).toBeGreaterThan(1);
const subIds = policyAutomations?.subItems?.map((s) => s.id) ?? [];
expect(subIds).toContain("manage-policy-automations-install-software");
expect(subIds).toContain("manage-policy-automations-calendar");
});
it("excludes team-scoped policy automations when no team selected", () => {
const items = buildPaletteItems(BASE_CONTEXT);
const policyAutomations = items.find(
(i) => i.id === "manage-policy-automations"
);
// Only webhooks should be present (no team-scoped items)
expect(policyAutomations?.subItems?.length).toBe(1);
expect(policyAutomations?.subItems?.[0].id).toBe(
"manage-policy-automations-webhooks"
);
});
it("on Unassigned, shows install-software / run-script / conditional-access but NOT calendar", () => {
// ManagePoliciesPage allows these three automations on No team
// but disables Calendar events without a specific fleet. The
// palette must match — earlier all four were gated together on
// hasTeamSelected, which dropped them all on Unassigned.
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
});
const policyAutomations = items.find(
(i) => i.id === "manage-policy-automations"
);
const subIds = policyAutomations?.subItems?.map((s) => s.id) ?? [];
expect(subIds).toContain("manage-policy-automations-webhooks");
expect(subIds).toContain("manage-policy-automations-install-software");
expect(subIds).toContain("manage-policy-automations-run-script");
expect(subIds).toContain("manage-policy-automations-conditional-access");
expect(subIds).not.toContain("manage-policy-automations-calendar");
});
it("excludes certificates and passwords for technicians", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
isTechnician: true,
hasTeamSelected: true,
currentTeam: { id: 1, name: "Engineering" },
});
const osSettings = items.find((i) => i.id === "controls-os-settings");
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
expect(subIds).not.toContain("controls-certificates");
expect(subIds).not.toContain("controls-passwords");
expect(subIds).toContain("controls-disk-encryption");
});
it("includes certificates and passwords for non-technicians", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: true,
currentTeam: { id: 1, name: "Engineering" },
});
const osSettings = items.find((i) => i.id === "controls-os-settings");
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
expect(subIds).toContain("controls-certificates");
expect(subIds).toContain("controls-passwords");
});
it("appends fleet_id via withTeamId for team-scoped paths", () => {
const mockWithTeamId = (path: string) => `${path}?fleet_id=5`;
const items = buildPaletteItems({
...BASE_CONTEXT,
withTeamId: mockWithTeamId,
hasTeamSelected: true,
currentTeam: { id: 5, name: "Eng" },
});
const dashboard = items.find((i) => i.id === "dashboard");
expect(dashboard?.path).toContain("fleet_id=5");
});
it("includes manage software automations without a teamName chip on All fleets", () => {
// Only visible on All fleets, destination is All fleets — no switch,
// no chip.
const items = buildPaletteItems(BASE_CONTEXT);
const swAuto = items.find((i) => i.id === "manage-software-automations");
expect(swAuto).toBeDefined();
expect(swAuto?.teamName).toBeUndefined();
});
it("calls onToggleDarkMode for the dark mode item", () => {
const mockToggle = jest.fn();
const items = buildPaletteItems({
...BASE_CONTEXT,
onToggleDarkMode: mockToggle,
});
const darkMode = items.find((i) => i.id === "toggle-dark-mode");
expect(darkMode).toBeDefined();
darkMode?.onAction?.();
expect(mockToggle).toHaveBeenCalled();
});
it("toggle-dark-mode label reflects the isDarkMode context flag", () => {
const lightItems = buildPaletteItems({
...BASE_CONTEXT,
isDarkMode: false,
});
expect(lightItems.find((i) => i.id === "toggle-dark-mode")?.label).toBe(
"Switch to dark mode"
);
const darkItems = buildPaletteItems({
...BASE_CONTEXT,
isDarkMode: true,
});
expect(darkItems.find((i) => i.id === "toggle-dark-mode")?.label).toBe(
"Switch to light mode"
);
});
it("hides software add, script, and variable actions on All fleets", () => {
const items = buildPaletteItems(BASE_CONTEXT);
const ids = items.map((i) => i.id);
expect(ids).not.toContain("add-fleet-maintained-app");
expect(ids).not.toContain("add-vpp-app");
expect(ids).not.toContain("add-android-app-store-app");
expect(ids).not.toContain("add-custom-package");
expect(ids).not.toContain("add-script");
expect(ids).not.toContain("add-custom-variable");
});
it("shows software add, script, and variable actions on Unassigned", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
});
const ids = items.map((i) => i.id);
expect(ids).toContain("add-fleet-maintained-app");
expect(ids).toContain("add-vpp-app");
expect(ids).toContain("add-android-app-store-app");
expect(ids).toContain("add-custom-package");
expect(ids).toContain("add-script");
expect(ids).toContain("add-custom-variable");
});
it("hides 'Add script' for technicians (page-side button is disabled for them)", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
isTechnician: true,
});
const ids = items.map((i) => i.id);
expect(ids).not.toContain("add-script");
});
it("hides every software-add action when !canAddSoftware (current-team observer / cross-team admin / technician)", () => {
// A user who is admin of a different team has canWrite (via
// isAnyTeamAdmin) but isTeamAdmin(currentTeam) is false. The
// Add software button hides on the page; the palette must too.
// Same for technicians, who pass canWrite but never canAddSoftware.
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
canAddSoftware: false,
});
const ids = items.map((i) => i.id);
expect(ids).not.toContain("add-fleet-maintained-app");
expect(ids).not.toContain("add-vpp-app");
expect(ids).not.toContain("add-android-app-store-app");
expect(ids).not.toContain("add-custom-package");
// Sanity: non-software write actions still surface.
expect(ids).toContain("add-hosts");
});
it("hides 'Add custom variable' for team admins/maintainers (canWrite but !canEditCustomVariable)", () => {
// Mirrors a team-admin context: they have canWrite (so add-script,
// add-hosts, etc. show), but the Variables page rejects them, so
// the variable entry must not surface.
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
canEditCustomVariable: false,
});
const ids = items.map((i) => i.id);
expect(ids).toContain("add-script");
expect(ids).not.toContain("add-custom-variable");
});
it("hides the Users settings item for non-admins", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
canAccessSettings: false,
canManageSoftwareAutomations: false,
});
// `settings-users` lives in the Settings group and is gated on
// canAccessSettings. (The old `users-page` Pages-group entry was
// removed because it pointed to the same destination.)
expect(items.map((i) => i.id)).not.toContain("settings-users");
});
it("shows Software library on Unassigned but not All fleets", () => {
const allFleetsItems = buildPaletteItems(BASE_CONTEXT);
expect(allFleetsItems.map((i) => i.id)).not.toContain("software-library");
const unassignedItems = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
});
expect(unassignedItems.map((i) => i.id)).toContain("software-library");
});
it("includes Run live report and Run live policy for writers", () => {
const items = buildPaletteItems(BASE_CONTEXT);
const ids = items.map((i) => i.id);
expect(ids).toContain("run-live-report");
expect(ids).toContain("run-live-policy");
});
it("excludes Run live report and Run live policy for observers", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
canWrite: false,
canRunLiveReport: false,
canAccessControls: false,
canAccessSettings: false,
canManagePolicyAutomations: false,
canManageSoftwareAutomations: false,
});
const ids = items.map((i) => i.id);
expect(ids).not.toContain("run-live-report");
expect(ids).not.toContain("run-live-policy");
});
it("shows Create fleet only for admins", () => {
const adminItems = buildPaletteItems(BASE_CONTEXT);
expect(adminItems.map((i) => i.id)).toContain("create-fleet");
const nonAdminItems = buildPaletteItems({
...BASE_CONTEXT,
canAccessSettings: false,
canManageSoftwareAutomations: false,
});
expect(nonAdminItems.map((i) => i.id)).not.toContain("create-fleet");
});
});
describe("Fleet Free (isPremiumTier: false)", () => {
const FREE_CONTEXT = {
...BASE_CONTEXT,
isPremiumTier: false,
// Free has a single implicit fleet; mirror what AppContext would set.
hasTeamSelected: true as const,
currentTeam: { id: 1, name: "Engineering" },
};
it("hides all software-add commands", () => {
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
expect(ids).not.toContain("add-fleet-maintained-app");
expect(ids).not.toContain("add-vpp-app");
expect(ids).not.toContain("add-android-app-store-app");
expect(ids).not.toContain("add-custom-package");
});
it("hides the Setup Experience parent and all its sub-items", () => {
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
expect(ids).not.toContain("controls-setup-experience");
// Sub-items live under controls-setup-experience.subItems; absence
// of the parent is sufficient.
});
it("hides Disk encryption, Certificates, and Passwords OS-settings sub-items", () => {
const osSettings = buildPaletteItems(FREE_CONTEXT).find(
(i) => i.id === "controls-os-settings"
);
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
expect(subIds).not.toContain("controls-disk-encryption");
expect(subIds).not.toContain("controls-certificates");
expect(subIds).not.toContain("controls-passwords");
// Configuration profiles is not premium-gated; keep it.
expect(subIds).toContain("controls-custom-settings");
});
it("hides MDM ABM and VPP commands", () => {
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
expect(ids).not.toContain("add-abm");
expect(ids).not.toContain("edit-abm");
expect(ids).not.toContain("add-vpp");
expect(ids).not.toContain("edit-vpp");
});
it("hides premium integrations settings sub-items", () => {
const integrations = buildPaletteItems(FREE_CONTEXT).find(
(i) => i.id === "settings-integrations"
);
const subIds = integrations?.subItems?.map((s) => s.id) ?? [];
expect(subIds).not.toContain("settings-int-calendars");
expect(subIds).not.toContain("settings-int-change-management");
expect(subIds).not.toContain("settings-int-certificate-authorities");
expect(subIds).not.toContain("add-certificate-authority");
expect(subIds).not.toContain("settings-int-conditional-access");
});
it("hides settings-fleets, create-fleet, and view-software-library", () => {
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
expect(ids).not.toContain("settings-fleets");
expect(ids).not.toContain("create-fleet");
expect(ids).not.toContain("view-software-library");
});
});
describe("Primo Mode (isPrimoMode: true)", () => {
const PRIMO_CONTEXT = {
...BASE_CONTEXT,
isPrimoMode: true,
hasTeamSelected: true as const,
currentTeam: { id: 7, name: "Default" },
};
it("hides settings-fleets and create-fleet", () => {
const ids = buildPaletteItems(PRIMO_CONTEXT).map((i) => i.id);
expect(ids).not.toContain("settings-fleets");
expect(ids).not.toContain("create-fleet");
});
it("shows manage-software-automations (Primo treats single fleet as all)", () => {
const ids = buildPaletteItems(PRIMO_CONTEXT).map((i) => i.id);
expect(ids).toContain("manage-software-automations");
});
it("suppresses all teamName chips because Primo never switches fleet context", () => {
const items = buildPaletteItems(PRIMO_CONTEXT);
const itemsWithChips = items.filter((i) => i.teamName !== undefined);
expect(itemsWithChips).toHaveLength(0);
});
it("does not synthesize an 'Unassigned' or 'All fleets' chip on add-hosts", () => {
const addHosts = buildPaletteItems(PRIMO_CONTEXT).find(
(i) => i.id === "add-hosts"
);
expect(addHosts?.teamName).toBeUndefined();
});
});
describe("GitOps Mode", () => {
it("hides create-fleet when GitOps mode is configured", () => {
const gitopsConfig = createMockConfig();
gitopsConfig.gitops = {
...gitopsConfig.gitops,
gitops_mode_enabled: true,
repository_url: "https://github.com/fleetdm/fleet-config",
};
const ids = buildPaletteItems({
...BASE_CONTEXT,
config: gitopsConfig,
}).map((i) => i.id);
expect(ids).not.toContain("create-fleet");
});
});
});
@@ -0,0 +1,109 @@
import { ITeamSummary } from "interfaces/team";
import { IConfig } from "interfaces/config";
import { deriveContext } from "./groups/derivations";
import buildPagesItems from "./groups/pages";
import buildControlsItems from "./groups/controls";
import buildSoftwareItems from "./groups/software";
import buildSettingsItems from "./groups/settings";
import buildCommandsItems from "./groups/commands";
import buildMdmItems from "./groups/mdm";
import buildAutomationsItems from "./groups/automations";
export interface ICommandSubItem {
id: string;
label: string;
path: string;
keywords?: string[];
}
export interface ICommandItem {
id: string;
label: string;
group: string;
path?: string;
keywords?: string[];
/** Displayed on the right when navigating would switch your team context */
teamName?: string;
/** Nested items shown when the parent is expanded via chevron */
subItems?: ICommandSubItem[];
/** Custom action instead of navigation */
onAction?: () => void;
/** True when selecting this item opens a sub-page (not a navigation). */
opensSubPage?: boolean;
}
export interface ICommandPaletteContext {
search: string;
currentTeam?: ITeamSummary;
availableTeams?: ITeamSummary[];
config: IConfig | null;
canAccessControls?: boolean;
canWrite?: boolean;
canRunLiveReport?: boolean;
canAccessSettings?: boolean;
canManagePolicyAutomations?: boolean;
canManageSoftwareAutomations?: boolean;
/** Mirrors Variables.tsx `canEdit` only global admins and global
* maintainers can create custom variables. canWrite includes team
* roles and technicians, which the destination page rejects, so
* the palette uses this narrower flag for `add-custom-variable`. */
canEditCustomVariable?: boolean;
/** Mirrors SoftwarePage.tsx `canAddSoftware`:
* isGlobalAdmin || isGlobalMaintainer || isTeamAdmin (current team)
* || isTeamMaintainer (current team). Excludes technicians and
* cross-team admin/maintainers neither can use the Add software
* page despite passing `canWrite`. Gates every software-add palette
* item (FMA, VPP, Android, custom package). */
canAddSoftware?: boolean;
isTechnician?: boolean;
isPremiumTier?: boolean;
isPrimoMode?: boolean;
/** Reactive theme state true when dark mode is active. Passed in so
* the toggle-dark-mode label re-renders when the user (or system)
* changes the theme while the palette is open. */
isDarkMode?: boolean;
isMacMdmEnabledAndConfigured?: boolean;
isWindowsMdmEnabledAndConfigured?: boolean;
isAndroidMdmEnabledAndConfigured?: boolean;
isVppEnabled?: boolean;
hasTeamSelected?: boolean;
withTeamId: (path: string) => string;
onToggleDarkMode: () => void;
onViewHost: () => void;
onViewSoftware: () => void;
onViewSoftwareLibrary: () => void;
onViewReport: () => void;
onViewPolicy: () => void;
}
export const GROUPS = [
"Pages",
"Controls",
"Software",
"Settings",
"MDM",
"Automations",
"Commands",
] as const;
/**
* Top-level orchestrator. Each group's items live in its own file under
* ./groups/, and all share the values derived once via deriveContext.
* Cross-group order in the returned array doesn't affect rendering
* CommandPalette.tsx groups by `group` field and renders in GROUPS order.
*/
export const buildPaletteItems = (
ctx: ICommandPaletteContext
): ICommandItem[] => {
const derived = deriveContext(ctx);
return [
...buildPagesItems(ctx, derived),
...buildControlsItems(ctx, derived),
...buildSoftwareItems(ctx, derived),
...buildSettingsItems(ctx),
...buildCommandsItems(ctx, derived),
...buildMdmItems(ctx, derived),
...buildAutomationsItems(ctx, derived),
];
};
@@ -0,0 +1 @@
export { default } from "./CommandPalette";
@@ -100,7 +100,7 @@ const getInstallIconType = (
return isSelfService ? "selfService" : "manual";
};
const InstallIconWithTooltip = ({
export const InstallIconWithTooltip = ({
isSelfService,
automaticInstallPoliciesCount,
pageContext,
@@ -12,6 +12,19 @@
height: 24px;
}
&__install-icon-with-tooltip {
display: inline-flex;
align-items: center;
// TooltipWrapper renders its child inside a span that defaults to
// text-baseline alignment. Re-center so the icon sits in the row's
// vertical middle wherever this component is used.
.component__tooltip-wrapper__element {
display: inline-flex;
align-items: center;
}
}
&__install-tooltip-text {
font-weight: $regular;
font-size: $xx-small;
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
interface IArrowLeftProps {
color?: Colors;
}
const ArrowLeft = ({ color = "ui-fleet-black-75" }: IArrowLeftProps) => {
return (
<svg
width="16"
height="16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.707 3.707a1 1 0 0 0-1.414-1.414l-5 5a1 1 0 0 0 0 1.414l5 5a1 1 0 0 0 1.414-1.414L3.414 9H15a1 1 0 0 0 0-2H3.414l3.293-3.293Z"
fill={COLORS[color]}
/>
</svg>
);
};
export default ArrowLeft;
+2
View File
@@ -1,5 +1,6 @@
import Arrow from "./Arrow";
import ArrowInternalLink from "./ArrowInternalLink";
import ArrowLeft from "./ArrowLeft";
import Calendar from "./Calendar";
import CalendarCheck from "./CalendarCheck";
import Check from "./Check";
@@ -77,6 +78,7 @@ import Android from "./Android";
export const ICON_MAP = {
arrow: Arrow,
"arrow-internal-link": ArrowInternalLink,
"arrow-left": ArrowLeft,
calendar: Calendar,
"calendar-check": CalendarCheck,
"chevron-left": ChevronLeft,
@@ -13,6 +13,7 @@ import paths from "router/paths";
import useDeepEffect from "hooks/useDeepEffect";
import FlashMessage from "components/FlashMessage";
import SiteTopNav from "components/top_nav/SiteTopNav";
import CommandPalette from "components/CommandPalette";
import { QueryParams } from "utilities/url";
import shouldShowUnsupportedScreen from "layouts/UnsupportedScreenSize/helpers";
@@ -71,6 +72,7 @@ const CoreLayout = ({ children, router, location }: ICoreLayoutProps) => {
return (
<div className="app-wrap">
<CommandPalette />
{shouldShowUnsupportedScreen(location.pathname) && (
<UnsupportedScreenSize />
)}
@@ -97,6 +97,7 @@ interface IDashboardProps {
hash?: string;
query: {
fleet_id?: string;
manage_automations?: string;
};
};
}
@@ -171,6 +172,16 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
JSX.Element | string | null
>();
// Open activity feed automations modal via query param (e.g. from command palette)
useEffect(() => {
if (location.query.manage_automations === "1") {
setShowActivityFeedAutomationsModal(true);
// Clean up the query param from the URL, preserving other params
const { manage_automations, ...rest } = location.query;
router.replace({ pathname, query: rest });
}
}, [location.query.manage_automations, pathname, router]);
useEffect(() => {
const platformByPathname =
PLATFORM_DROPDOWN_OPTIONS?.find((platform) => platform.path === pathname)
@@ -1,4 +1,10 @@
import React, { useCallback, useContext, useRef, useState } from "react";
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { AxiosError } from "axios";
import { useQuery } from "react-query";
@@ -53,6 +59,26 @@ const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => {
const [showEditScriptModal, setShowEditScriptModal] = useState(false);
const [showAddScriptModal, setShowAddScriptModal] = useState(false);
// Open the add-script modal when arriving from the command palette
// with `?add_script=1`. Cleans the param so a refresh doesn't reopen
// the modal. Technicians never see the "Add script" button on this
// page, so don't honor the deep-link for them either.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("add_script") === "1") {
if (!isTechnician) {
setShowAddScriptModal(true);
}
params.delete("add_script");
const qs = params.toString();
window.history.replaceState(
{},
"",
qs ? `${window.location.pathname}?${qs}` : window.location.pathname
);
}
}, [isTechnician]);
const selectedScript = useRef<IScript | null>(null);
const {
@@ -118,6 +118,7 @@ interface ISoftwarePageProps {
self_service?: string;
vulnerable?: string;
exploit?: string;
manage_automations?: string;
min_cvss_score?: string;
max_cvss_score?: string;
page?: string;
@@ -233,6 +234,27 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
const isSoftwareConfigLoaded =
!isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
// Open manage automations modal via query param (e.g. from command palette)
useEffect(() => {
if (
queryParams?.manage_automations === "1" &&
isSoftwareConfigLoaded &&
(isAllTeamsSelected || isPrimoMode)
) {
setShowManageAutomationsModal(true);
// Clean up the query param from the URL
const { manage_automations, ...rest } = queryParams;
router.replace({ pathname: location.pathname, query: rest });
}
}, [
queryParams?.manage_automations,
isSoftwareConfigLoaded,
isAllTeamsSelected,
isPrimoMode,
location.pathname,
router,
]);
const toggleManageAutomationsModal = useCallback(() => {
setShowManageAutomationsModal(!showManageAutomationsModal);
}, [setShowManageAutomationsModal, showManageAutomationsModal]);
@@ -1,4 +1,10 @@
import React, { useState, useCallback, useContext, useMemo } from "react";
import React, {
useState,
useCallback,
useContext,
useEffect,
useMemo,
} from "react";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
@@ -46,6 +52,21 @@ const ManageFleetsPage = (): JSX.Element => {
const [isUpdatingFleets, setIsUpdatingFleets] = useState(false);
const [showCreateFleetModal, setShowCreateFleetModal] = useState(false);
// Open the create modal when arriving with ?create_fleet=1 (e.g., from
// the command palette). Then strip the param so refreshes don't reopen it.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("create_fleet") === "1") {
setShowCreateFleetModal(true);
params.delete("create_fleet");
const qs = params.toString();
const next = qs
? `${window.location.pathname}?${qs}`
: window.location.pathname;
window.history.replaceState(null, "", next);
}
}, []);
const [showDeleteFleetModal, setShowDeleteFleetModal] = useState(false);
const [showRenameFleetModal, setShowRenameFleetModal] = useState(false);
const [fleetEditing, setFleetEditing] = useState<IFleet>();
@@ -236,6 +236,24 @@ const ManageHostsPage = ({
const [showDeleteHostModal, setShowDeleteHostModal] = useState(false);
const [showRunScriptBatchModal, setShowRunScriptBatchModal] = useState(false);
// Open add hosts modal via query param (e.g. from command palette)
useEffect(() => {
if (queryParams?.add_hosts === "1") {
setShowAddHostsModal(true);
const { add_hosts, ...rest } = queryParams;
router.replace({ pathname: location.pathname, query: rest });
}
}, [queryParams?.add_hosts, location.pathname, router]);
// Open enroll secrets modal via query param (e.g. from command palette)
useEffect(() => {
if (queryParams?.manage_enroll_secrets === "1") {
setShowEnrollSecretModal(true);
const { manage_enroll_secrets, ...rest } = queryParams;
router.replace({ pathname: location.pathname, query: rest });
}
}, [queryParams?.manage_enroll_secrets, location.pathname, router]);
const [hiddenColumns, setHiddenColumns] = useState<string[]>(
userSettings?.hidden_host_columns || defaultHiddenColumns
);
@@ -97,6 +97,7 @@ interface IManagePoliciesPageProps {
order_direction?: "asc" | "desc";
page?: string;
automation_type?: AutomationType;
manage_automations?: string;
};
search: string;
};
@@ -566,6 +567,34 @@ const ManagePolicyPage = ({
}
};
// Open specific policy automation modal via query param (e.g. from command palette)
useEffect(() => {
const param = queryParams?.manage_automations;
if (!param) return;
switch (param) {
case "webhooks":
setShowOtherWorkflowsModal(true);
break;
case "install_software":
setShowInstallSoftwareModal(true);
break;
case "run_script":
setShowPolicyRunScriptModal(true);
break;
case "calendar":
setShowCalendarEventsModal(true);
break;
case "conditional_access":
setShowConditionalAccessModal(true);
break;
default:
}
const { manage_automations, ...rest } = queryParams;
router.replace({ pathname: location.pathname, query: rest });
}, [queryParams?.manage_automations]);
const onUpdateOtherWorkflows = async (requestBody: {
webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">;
integrations: IZendeskJiraIntegrations;
@@ -61,6 +61,7 @@ interface IManageQueriesPageProps {
order_key?: string;
order_direction?: "asc" | "desc";
fleet_id?: string;
manage_automations?: string;
};
search: string;
};
@@ -129,6 +130,15 @@ const ManageQueriesPage = ({
const [isUpdatingQueries, setIsUpdatingQueries] = useState(false);
const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false);
// Open manage automations modal via query param (e.g. from command palette)
useEffect(() => {
if (location.query.manage_automations === "1") {
setShowManageAutomationsModal(true);
const { manage_automations, ...rest } = location.query;
router.replace({ pathname: location.pathname, query: rest });
}
}, [location.query.manage_automations, location.pathname, router]);
const curPageFromURL = location.query.page
? parseInt(location.query.page, 10)
: 0;
+1
View File
@@ -20,6 +20,7 @@
"@types/dompurify": "3.0.2",
"ace-builds": "1.4.14",
"axios": "1.15.2",
"cmdk": "1.1.1",
"content-disposition": "0.5.4",
"core-js": "3.25.1",
"date-fns": "3.6.0",
+212
View File
@@ -2229,6 +2229,149 @@
"@parcel/watcher-win32-ia32" "2.5.6"
"@parcel/watcher-win32-x64" "2.5.6"
"@radix-ui/primitive@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==
"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
"@radix-ui/react-context@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==
"@radix-ui/react-dialog@^1.1.6":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-dismissable-layer" "1.1.11"
"@radix-ui/react-focus-guards" "1.1.3"
"@radix-ui/react-focus-scope" "1.1.7"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-portal" "1.1.9"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-dismissable-layer@1.1.11":
version "1.1.11"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz#e33ab6f6bdaa00f8f7327c408d9f631376b88b37"
integrity sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-escape-keydown" "1.1.1"
"@radix-ui/react-focus-guards@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz#2a5669e464ad5fde9f86d22f7fdc17781a4dfa7f"
integrity sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==
"@radix-ui/react-focus-scope@1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz#dfe76fc103537d80bf42723a183773fd07bfb58d"
integrity sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7"
integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-portal@1.1.9":
version "1.1.9"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472"
integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-presence@1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db"
integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-primitive@2.1.3":
version "2.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc"
integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==
dependencies:
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-primitive@^2.0.2":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz#2626ea309ebd63bf5767d3e7fc4081f81b993df0"
integrity sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==
dependencies:
"@radix-ui/react-slot" "1.2.4"
"@radix-ui/react-slot@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-slot@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83"
integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-use-callback-ref@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40"
integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==
"@radix-ui/react-use-controllable-state@1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190"
integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==
dependencies:
"@radix-ui/react-use-effect-event" "0.0.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-use-effect-event@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907"
integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-use-escape-keydown@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29"
integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==
dependencies:
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-layout-effect@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
"@reduxjs/toolkit@^1.9.0 || 2.x.x":
version "2.11.2"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82"
@@ -3706,6 +3849,13 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.2.4:
version "1.2.6"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.6.tgz#73051c9b088114c795b1ea414e9c0fff874ffc1a"
integrity sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==
dependencies:
tslib "^2.0.0"
aria-query@5.3.0:
version "5.3.0"
resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz"
@@ -4534,6 +4684,16 @@ clsx@^2.1.1:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
cmdk@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-1.1.1.tgz#b8524272699ccaa37aaf07f36850b376bf3d58e5"
integrity sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==
dependencies:
"@radix-ui/react-compose-refs" "^1.1.1"
"@radix-ui/react-dialog" "^1.1.6"
"@radix-ui/react-id" "^1.1.0"
"@radix-ui/react-primitive" "^2.0.2"
co@^4.6.0:
version "4.6.0"
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"
@@ -5155,6 +5315,11 @@ detect-newline@^3.0.0:
resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz"
integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
detect-node@^2.0.4, detect-node@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz"
@@ -6412,6 +6577,11 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@
hasown "^2.0.2"
math-intrinsics "^1.1.0"
get-nonce@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-package-type@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz"
@@ -10044,6 +10214,25 @@ react-query@3.39.3:
"@types/use-sync-external-store" "^0.0.6"
use-sync-external-store "^1.4.0"
react-remove-scroll-bar@^2.3.7:
version "2.3.8"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223"
integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==
dependencies:
react-style-singleton "^2.2.2"
tslib "^2.0.0"
react-remove-scroll@^2.6.3:
version "2.7.2"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz#6442da56791117661978ae99cd29be9026fecca0"
integrity sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==
dependencies:
react-remove-scroll-bar "^2.3.7"
react-style-singleton "^2.2.3"
tslib "^2.1.0"
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
react-router-dom@^4.1.1:
version "4.3.1"
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.3.1.tgz"
@@ -10114,6 +10303,14 @@ react-select@1.3.0:
prop-types "^15.5.8"
react-input-autosize "^2.1.2"
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==
dependencies:
get-nonce "^1.0.0"
tslib "^2.0.0"
react-table@7.7.0:
version "7.7.0"
resolved "https://registry.npmjs.org/react-table/-/react-table-7.7.0.tgz"
@@ -11653,11 +11850,26 @@ url@^0.11.0:
punycode "^1.4.1"
qs "^6.12.3"
use-callback-ref@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf"
integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==
dependencies:
tslib "^2.0.0"
use-debounce@9.0.4:
version "9.0.4"
resolved "https://registry.npmjs.org/use-debounce/-/use-debounce-9.0.4.tgz"
integrity sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==
use-sidecar@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb"
integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==
dependencies:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"