Fleet UI: "Fleet spotlight" (Command palette menu) (#43756)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user