From 968ea20aeb34966f24bfebd96bfd75ab31fbd3e9 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:26:04 -0700 Subject: [PATCH] Fleet UI: Searchable fleets dropdown with add-fleet affordance (#49690) --- changes/34368-searchable-fleets-dropdown | 1 + .../FleetsDropdown/FleetsDropdown.stories.tsx | 157 +++++ .../FleetsDropdown/FleetsDropdown.tests.tsx | 390 +++++++++++ .../FleetsDropdown/FleetsDropdown.tsx | 629 ++++++++++++++++++ .../components/FleetsDropdown/_styles.scss | 142 ++++ frontend/components/FleetsDropdown/index.tsx | 1 + .../TeamsDropdown/TeamsDropdown.stories.tsx | 30 - .../TeamsDropdown/TeamsDropdown.tests.tsx | 88 --- .../TeamsDropdown/TeamsDropdown.tsx | 318 --------- .../components/TeamsDropdown/_styles.scss | 1 - frontend/components/TeamsDropdown/index.tsx | 1 - .../components/TeamsHeader/TeamsHeader.tsx | 10 +- frontend/docs/patterns.md | 2 +- .../pages/DashboardPage/DashboardPage.tsx | 8 +- .../ActivityTypeDropdown/_styles.scss | 31 +- .../ManageControlsPage/ManageControlsPage.tsx | 12 +- .../SelfServiceCategoriesPage.tests.tsx | 4 +- .../SelfServiceCategoriesPage.tsx | 12 +- .../SelfServiceCategoriesPage/_styles.scss | 2 +- .../SoftwareOSDetailsPage/_styles.scss | 2 +- .../SoftwareTitleDetailsPage/_styles.scss | 2 +- .../SoftwareVersionDetailsPage/_styles.scss | 2 +- .../_styles.scss | 2 +- .../TeamDetailsWrapper/TeamDetailsWrapper.tsx | 10 +- .../hosts/ManageHostsPage/ManageHostsPage.tsx | 10 +- .../components/CategoryFilter/_styles.scss | 30 +- .../ManagePoliciesPage/ManagePoliciesPage.tsx | 10 +- .../ManageQueriesPage/ManageQueriesPage.tsx | 8 +- .../SaveAsNewQueryModal.tsx | 8 +- frontend/styles/var/mixins.scss | 33 + 30 files changed, 1407 insertions(+), 549 deletions(-) create mode 100644 changes/34368-searchable-fleets-dropdown create mode 100644 frontend/components/FleetsDropdown/FleetsDropdown.stories.tsx create mode 100644 frontend/components/FleetsDropdown/FleetsDropdown.tests.tsx create mode 100644 frontend/components/FleetsDropdown/FleetsDropdown.tsx create mode 100644 frontend/components/FleetsDropdown/_styles.scss create mode 100644 frontend/components/FleetsDropdown/index.tsx delete mode 100644 frontend/components/TeamsDropdown/TeamsDropdown.stories.tsx delete mode 100644 frontend/components/TeamsDropdown/TeamsDropdown.tests.tsx delete mode 100644 frontend/components/TeamsDropdown/TeamsDropdown.tsx delete mode 100644 frontend/components/TeamsDropdown/_styles.scss delete mode 100644 frontend/components/TeamsDropdown/index.tsx diff --git a/changes/34368-searchable-fleets-dropdown b/changes/34368-searchable-fleets-dropdown new file mode 100644 index 0000000000..bf8b741de6 --- /dev/null +++ b/changes/34368-searchable-fleets-dropdown @@ -0,0 +1 @@ +- Reworked the fleets dropdown to make the search input discoverable at 10+ fleets and added an "Add fleet" affordance for global admins. diff --git a/frontend/components/FleetsDropdown/FleetsDropdown.stories.tsx b/frontend/components/FleetsDropdown/FleetsDropdown.stories.tsx new file mode 100644 index 0000000000..38d4998765 --- /dev/null +++ b/frontend/components/FleetsDropdown/FleetsDropdown.stories.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { noop } from "lodash"; + +import { AppContext, initialState } from "context/app"; + +import FleetsDropdown from "."; + +// Fleet names lifted from the Figma design so stories match reviewer visuals. +const FLEETS_FEW = [ + { id: -1, name: "All fleets" }, + { id: 0, name: "Unassigned" }, + { id: 1, name: "Servers" }, + { id: 2, name: "Servers (canary)" }, + { id: 3, name: "Workstations" }, +]; + +const FLEETS_MANY = [ + { id: -1, name: "All fleets" }, + { id: 0, name: "Unassigned" }, + { id: 1, name: "Servers" }, + { id: 2, name: "Servers (canary)" }, + { id: 3, name: "Workstations" }, + { id: 4, name: "Testing & QA" }, + { id: 5, name: "Employee-issued mobile devices" }, + { id: 6, name: "Personal mobile devices" }, + { id: 7, name: "IT servers" }, + { id: 8, name: "TV media centers" }, + { id: 9, name: "Smart fridges" }, +]; + +const FLEETS_SCROLLABLE = [ + ...FLEETS_MANY, + { id: 10, name: "Company-owned wearables" }, + { id: 11, name: "CEO exception devices" }, + { id: 12, name: "Company-owned mobile devices" }, + { id: 13, name: "Contractor-owned laptops" }, + { id: 14, name: "Regional office desktops" }, + { id: 15, name: "Kiosk terminals" }, + { id: 16, name: "Retail POS systems" }, +]; + +const withAppContext = (isGlobalAdmin: boolean) => ( + Story: React.ComponentType +) => ( + + {/* minHeight matches the menu's runtime maxHeight (715px) plus room for + the trigger, so scrollable-list stories render the full open menu + without clipping. */} +
+ +
+
+); + +const meta: Meta = { + title: "Components/FleetsDropdown", + component: FleetsDropdown, + args: { + currentUserFleets: FLEETS_MANY, + includeUnassigned: true, + onChange: noop, + }, +}; + +export default meta; + +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Below the search threshold (<10 rows) +// --------------------------------------------------------------------------- + +export const FewFleetsAsAdmin: Story = { + name: "Few fleets — global admin (no search, footer only)", + args: { currentUserFleets: FLEETS_FEW }, + decorators: [withAppContext(true)], +}; + +export const FewFleetsAsNonAdmin: Story = { + name: "Few fleets — non-admin (no search, no footer)", + args: { currentUserFleets: FLEETS_FEW }, + decorators: [withAppContext(false)], +}; + +// --------------------------------------------------------------------------- +// At the search threshold, still fits without scroll (10–14 rows) +// --------------------------------------------------------------------------- + +export const ManyFleetsAsAdmin: Story = { + name: "Many fleets — global admin (search + footer, no scroll)", + args: { currentUserFleets: FLEETS_MANY }, + decorators: [withAppContext(true)], +}; + +export const ManyFleetsAsNonAdmin: Story = { + name: "Many fleets — non-admin (search only, no scroll)", + args: { currentUserFleets: FLEETS_MANY }, + decorators: [withAppContext(false)], +}; + +// --------------------------------------------------------------------------- +// Beyond the scroll threshold (15+ rows) — scroll-fade appears +// --------------------------------------------------------------------------- + +export const ScrollableAsAdmin: Story = { + name: "Scrollable list — global admin (search + fade + footer)", + args: { currentUserFleets: FLEETS_SCROLLABLE }, + decorators: [withAppContext(true)], +}; + +export const ScrollableAsNonAdmin: Story = { + name: "Scrollable list — non-admin (search + fade only)", + args: { currentUserFleets: FLEETS_SCROLLABLE }, + decorators: [withAppContext(false)], +}; + +// --------------------------------------------------------------------------- +// Variants +// --------------------------------------------------------------------------- + +export const AsFormField: Story = { + name: "As form field (Save as new report modal)", + args: { + currentUserFleets: FLEETS_MANY, + asFormField: true, + includeAllFleets: false, + selectedFleetId: 1, + }, + decorators: [withAppContext(true)], +}; + +export const Disabled: Story = { + args: { + currentUserFleets: FLEETS_MANY, + isDisabled: true, + }, + decorators: [withAppContext(true)], +}; + +export const LongFleetName: Story = { + name: "Long fleet name (trigger + option truncation)", + args: { + currentUserFleets: [ + { id: -1, name: "All fleets" }, + { + id: 1, + name: + "Employee-issued mobile devices in the west-coast satellite offices", + }, + { id: 2, name: "Workstations" }, + { id: 3, name: "Servers" }, + ], + selectedFleetId: 1, + }, + decorators: [withAppContext(true)], +}; diff --git a/frontend/components/FleetsDropdown/FleetsDropdown.tests.tsx b/frontend/components/FleetsDropdown/FleetsDropdown.tests.tsx new file mode 100644 index 0000000000..a508528056 --- /dev/null +++ b/frontend/components/FleetsDropdown/FleetsDropdown.tests.tsx @@ -0,0 +1,390 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { noop } from "lodash"; +// TODO: Replace renderWithAppContext with createCustomRenderer (inherited +// from pre-rename TeamsDropdown.tests.tsx). +import { renderWithAppContext } from "test/test-utils"; +import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; +import createMockConfig from "__mocks__/configMock"; + +import FleetsDropdown from "./FleetsDropdown"; + +const mockPush = jest.fn(); +jest.mock("react-router", () => ({ + browserHistory: { + push: (...args: unknown[]) => mockPush(...args), + }, +})); + +// The visible trigger is a real Fleet + + + ); + + await user.click(getTrigger(/Fleet 1/)); + fireEvent.change(screen.getByPlaceholderText("Search fleets"), { + target: { value: "Fleet 2" }, + }); + expect(screen.getByPlaceholderText("Search fleets")).toHaveValue( + "Fleet 2" + ); + + // Click on an element outside the dropdown wrapper. + fireEvent.mouseDown(screen.getByRole("button", { name: /outside/i })); + + // Menu closes. + expect( + screen.queryByPlaceholderText("Search fleets") + ).not.toBeInTheDocument(); + + // Reopen — the search input should be empty, not stuck on "Fleet 2". + await user.click(getTrigger(/Fleet 1/)); + expect(screen.getByPlaceholderText("Search fleets")).toHaveValue(""); + }); + + it("Escape on the search input closes the menu via the forwardNavKey bridge", async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(getTrigger(/Fleet 1/)); + expect(screen.getByPlaceholderText("Search fleets")).toBeInTheDocument(); + + // Escape hits the search input's onKeyDown, gets forwarded to + // react-select's hidden input, which closes the menu. If the bridge + // ever regresses, the search input stays mounted. + fireEvent.keyDown(screen.getByPlaceholderText("Search fleets"), { + key: "Escape", + }); + + expect( + screen.queryByPlaceholderText("Search fleets") + ).not.toBeInTheDocument(); + }); + }); + + describe("Add fleet button", () => { + const MANY_FLEETS = [ + { id: -1, name: "All fleets" }, + { id: 1, name: "Fleet 1" }, + { id: 2, name: "Fleet 2" }, + { id: 3, name: "Fleet 3" }, + { id: 4, name: "Fleet 4" }, + { id: 5, name: "Fleet 5" }, + { id: 6, name: "Fleet 6" }, + { id: 7, name: "Fleet 7" }, + { id: 8, name: "Fleet 8" }, + { id: 9, name: "Fleet 9" }, + ]; + + it("does not render for non-global-admin users", async () => { + const user = userEvent.setup(); + renderWithAppContext( + , + { contextValue: { isGlobalAdmin: false } } + ); + + await user.click(getTrigger(/Fleet 1/)); + expect( + screen.queryByRole("button", { name: /add fleet/i }) + ).not.toBeInTheDocument(); + }); + + it("renders as a labeled footer for global admins when the list is short", async () => { + const user = userEvent.setup(); + renderWithAppContext( + , + { contextValue: { isGlobalAdmin: true } } + ); + + await user.click(getTrigger(/Fleet 1/)); + const addButton = screen.getByRole("button", { name: /add fleet/i }); + expect(addButton).toHaveTextContent("Add fleet"); + + await user.click(addButton); + expect(mockPush).toHaveBeenCalledWith("/settings/fleets?create_fleet=1"); + }); + + it("renders the same labeled footer for global admins when the list is long", async () => { + const user = userEvent.setup(); + renderWithAppContext( + , + { contextValue: { isGlobalAdmin: true } } + ); + + await user.click(getTrigger(/Fleet 1/)); + const addButton = screen.getByRole("button", { name: /add fleet/i }); + expect(addButton).toHaveTextContent("Add fleet"); + + await user.click(addButton); + expect(mockPush).toHaveBeenCalledWith("/settings/fleets?create_fleet=1"); + }); + + it("Enter on Add fleet navigates without also selecting a highlighted fleet", async () => { + // Regression guard for the addFleetKeyDown handler: without + // stopPropagation on Enter/Space, the keydown would bubble to + // SelectContainer, react-select would treat it as "select the + // highlighted option," and onChange would fire in parallel with the + // Add fleet navigation. + const onChange = jest.fn(); + const user = userEvent.setup(); + renderWithAppContext( + , + { contextValue: { isGlobalAdmin: true } } + ); + + await user.click(getTrigger(/Fleet 1/)); + const addButton = screen.getByRole("button", { name: /add fleet/i }); + fireEvent.keyDown(addButton, { key: "Enter" }); + + // Navigation fired. + expect(mockPush).toHaveBeenCalledWith("/settings/fleets?create_fleet=1"); + // No parallel fleet selection. + expect(onChange).not.toHaveBeenCalled(); + }); + + it("hides the button for global admins when GitOps mode is enabled", async () => { + const user = userEvent.setup(); + renderWithAppContext( + , + { + contextValue: { + isGlobalAdmin: true, + config: createMockConfig({ + gitops: { + gitops_mode_enabled: true, + repository_url: "https://github.com/fleetdm/fleet", + exceptions: { labels: false, software: false, secrets: true }, + }, + }), + }, + } + ); + + await user.click(getTrigger(/Fleet 1/)); + expect( + screen.queryByRole("button", { name: /add fleet/i }) + ).not.toBeInTheDocument(); + }); + + it("hides the button for global admins when rendered as a form field", async () => { + const user = userEvent.setup(); + renderWithAppContext( + , + { contextValue: { isGlobalAdmin: true } } + ); + + await user.click(getTrigger(/Fleet 1/)); + expect( + screen.queryByRole("button", { name: /add fleet/i }) + ).not.toBeInTheDocument(); + }); + + it("hides the button for global admins when Primo mode is enabled", async () => { + const user = userEvent.setup(); + renderWithAppContext( + , + { + contextValue: { + isGlobalAdmin: true, + config: createMockConfig({ + partnerships: { enable_primo: true }, + }), + }, + } + ); + + await user.click(getTrigger(/Fleet 1/)); + expect( + screen.queryByRole("button", { name: /add fleet/i }) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/components/FleetsDropdown/FleetsDropdown.tsx b/frontend/components/FleetsDropdown/FleetsDropdown.tsx new file mode 100644 index 0000000000..010e5ff0b5 --- /dev/null +++ b/frontend/components/FleetsDropdown/FleetsDropdown.tsx @@ -0,0 +1,629 @@ +import React, { + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import Select, { + components, + GroupBase, + MenuListProps, + MenuProps, + SelectInstance, + StylesConfig, +} from "react-select-5"; +import { browserHistory } from "react-router"; +import classnames from "classnames"; + +import { COLORS } from "styles/var/colors"; +import { PADDING } from "styles/var/padding"; + +import { AppContext } from "context/app"; +import PATHS from "router/paths"; +import { getPathWithQueryParams } from "utilities/url"; +import { IDropdownOption } from "interfaces/dropdownOption"; +import { + APP_CONTEXT_ALL_TEAMS_ID, + APP_CONTEXT_ALL_TEAMS_SUMMARY, + APP_CONTEXT_NO_TEAM_ID, + ITeamSummary, +} from "interfaces/team"; + +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; + +declare module "react-select-5/dist/declarations/src/Select" { + // Generic parameter *names* must match react-select's own Props interface + // AND every other augmentation of it in the codebase (TS2428) — do not + // rename or underscore-prefix. IsMulti + Group are unused here by name; + // silenced with eslint-disable comments instead. + export interface Props< + Option, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + IsMulti extends boolean, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Group extends GroupBase