Fleet UI: Searchable fleets dropdown with add-fleet affordance (#49690)
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
) => (
|
||||
<AppContext.Provider value={{ ...initialState, isGlobalAdmin }}>
|
||||
{/* minHeight matches the menu's runtime maxHeight (715px) plus room for
|
||||
the trigger, so scrollable-list stories render the full open menu
|
||||
without clipping. */}
|
||||
<div style={{ minHeight: 780 }}>
|
||||
<Story />
|
||||
</div>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
|
||||
const meta: Meta<typeof FleetsDropdown> = {
|
||||
title: "Components/FleetsDropdown",
|
||||
component: FleetsDropdown,
|
||||
args: {
|
||||
currentUserFleets: FLEETS_MANY,
|
||||
includeUnassigned: true,
|
||||
onChange: noop,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FleetsDropdown>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)],
|
||||
};
|
||||
@@ -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 <Button>; getByRole("button") finds it
|
||||
// unambiguously (react-select's hidden control has no role="button").
|
||||
const getTrigger = (name: RegExp) =>
|
||||
screen.getByRole("button", { name, hidden: false });
|
||||
|
||||
describe("FleetsDropdown - component", () => {
|
||||
const USER_FLEETS = [
|
||||
{ id: -1, name: "All fleets" },
|
||||
{ id: 1, name: "Fleet 1" },
|
||||
{ id: 2, name: "Fleet 2" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
it("renders the given selected fleet from selectedFleetId", () => {
|
||||
render(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={USER_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getTrigger(/Fleet 1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the first fleet option when includeAllFleets is false and when no selectedFleetId is given", () => {
|
||||
render(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={USER_FLEETS}
|
||||
includeAllFleets={false}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getTrigger(/Fleet 1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'All fleets' when no selectedFleetId is given", () => {
|
||||
render(<FleetsDropdown currentUserFleets={USER_FLEETS} onChange={noop} />);
|
||||
|
||||
expect(getTrigger(/All fleets/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the first fleet when the current-user list has no 'All fleets' row and no selectedFleetId is given", () => {
|
||||
const withoutAllFleets = USER_FLEETS.filter(
|
||||
(t) => t.id > APP_CONTEXT_NO_TEAM_ID
|
||||
);
|
||||
render(
|
||||
<FleetsDropdown currentUserFleets={withoutAllFleets} onChange={noop} />
|
||||
);
|
||||
|
||||
expect(getTrigger(/Fleet 1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("in-menu search", () => {
|
||||
// Search only appears once the list has 10+ rows.
|
||||
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("hides the search input when there are fewer than 10 rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={USER_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(getTrigger(/Fleet 1/));
|
||||
expect(
|
||||
screen.queryByPlaceholderText("Search fleets")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a search input when there are 10 or more rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={MANY_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(getTrigger(/Fleet 1/));
|
||||
expect(screen.getByPlaceholderText("Search fleets")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters options by the search query", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={MANY_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(getTrigger(/Fleet 1/));
|
||||
fireEvent.change(screen.getByPlaceholderText("Search fleets"), {
|
||||
target: { value: "Fleet 2" },
|
||||
});
|
||||
|
||||
// The trigger button also contains "Fleet 1"; scope option lookups to
|
||||
// react-select's option class so the trigger doesn't count.
|
||||
const optionLabels = Array.from(
|
||||
document.querySelectorAll(".fleet-dropdown__option")
|
||||
).map((o) => o.textContent);
|
||||
expect(optionLabels).toContain("Fleet 2");
|
||||
expect(optionLabels).not.toContain("All fleets");
|
||||
});
|
||||
|
||||
it("shows the empty-state message when nothing matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={MANY_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(getTrigger(/Fleet 1/));
|
||||
fireEvent.change(screen.getByPlaceholderText("Search fleets"), {
|
||||
target: { value: "nothing-matches-this" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("No matching fleets")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("click outside the wrapper closes the menu and clears the search query", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<button type="button">outside</button>
|
||||
<FleetsDropdown
|
||||
currentUserFleets={MANY_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
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(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={MANY_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={USER_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>,
|
||||
{ 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(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={USER_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>,
|
||||
{ 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(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={MANY_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>,
|
||||
{ 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(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={MANY_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ 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(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={USER_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>,
|
||||
{
|
||||
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(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={USER_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
asFormField
|
||||
/>,
|
||||
{ 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(
|
||||
<FleetsDropdown
|
||||
currentUserFleets={USER_FLEETS}
|
||||
selectedFleetId={1}
|
||||
onChange={noop}
|
||||
/>,
|
||||
{
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Option>
|
||||
> {
|
||||
searchQuery?: string;
|
||||
onChangeSearchQuery?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
// Forwards navigation keys (Arrow/Enter/Escape) from the in-menu search
|
||||
// input to react-select's own (hidden) input so option highlighting and
|
||||
// selection still work while the search input has focus.
|
||||
forwardNavKey?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onClickAddFleet?: () => void;
|
||||
showAddFleetButton?: boolean;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Search input only appears once the option list has this many rows or more
|
||||
// — rows include "All fleets" and "Unassigned" alongside real fleets, so the
|
||||
// threshold is measured in rows rather than fleets. The "Add fleet" footer
|
||||
// always renders for global admins, regardless of row count.
|
||||
const MIN_ROWS_FOR_SEARCH = 10;
|
||||
|
||||
export interface INumberDropdownOption extends Omit<IDropdownOption, "value"> {
|
||||
value: number;
|
||||
}
|
||||
|
||||
const generateDropdownOptions = (
|
||||
fleets: ITeamSummary[] | undefined,
|
||||
includeAllFleets: boolean,
|
||||
includeUnassigned?: boolean
|
||||
): INumberDropdownOption[] => {
|
||||
if (!fleets) return [];
|
||||
|
||||
const options: INumberDropdownOption[] = fleets.map((fleet) => ({
|
||||
disabled: false,
|
||||
label: fleet.name,
|
||||
value: fleet.id,
|
||||
}));
|
||||
|
||||
// Filter the synthetic rows by ID (stable), not label — a real fleet
|
||||
// could legitimately be named "All fleets" or "Unassigned" and would
|
||||
// otherwise get dropped by a label-based check.
|
||||
return options.filter(
|
||||
(o) =>
|
||||
!(
|
||||
(o.value === APP_CONTEXT_NO_TEAM_ID && !includeUnassigned) ||
|
||||
(o.value === APP_CONTEXT_ALL_TEAMS_ID && !includeAllFleets)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const filterOptionsBySearch = (
|
||||
options: INumberDropdownOption[],
|
||||
searchQuery: string
|
||||
) => {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
if (query === "") return options;
|
||||
return options.filter((option) => {
|
||||
if (typeof option.label !== "string") return false;
|
||||
return option.label.toLowerCase().includes(query);
|
||||
});
|
||||
};
|
||||
|
||||
const NAV_KEYS = new Set(["ArrowDown", "ArrowUp", "Enter", "Escape"]);
|
||||
|
||||
interface IFleetsDropdownProps {
|
||||
currentUserFleets: ITeamSummary[];
|
||||
selectedFleetId?: number;
|
||||
includeAllFleets?: boolean;
|
||||
includeUnassigned?: boolean;
|
||||
isDisabled?: boolean;
|
||||
onChange: (newSelectedValue: number) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
/** Indicates that this fleets dropdown should be styled as a form field */
|
||||
asFormField?: boolean;
|
||||
}
|
||||
|
||||
const baseClass = "fleet-dropdown";
|
||||
|
||||
// Custom Menu wraps the search input (above) and the "Add fleet" footer
|
||||
// (below) *outside* the scrolling MenuList. Keeping them out of the scroll
|
||||
// container means the native scrollbar spans only the options area — it
|
||||
// doesn't run behind the sticky search or the sticky footer.
|
||||
const CustomMenu = (props: MenuProps<INumberDropdownOption, false>) => {
|
||||
const { selectProps } = props;
|
||||
const {
|
||||
searchQuery,
|
||||
onChangeSearchQuery,
|
||||
forwardNavKey,
|
||||
onClickAddFleet,
|
||||
showAddFleetButton,
|
||||
showSearch,
|
||||
} = selectProps;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleInputClick = (
|
||||
event: React.MouseEvent<HTMLInputElement, MouseEvent>
|
||||
) => {
|
||||
inputRef.current?.focus();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Stop propagation so the original event doesn't ALSO bubble to
|
||||
// SelectContainer and get processed a second time (double-fire on
|
||||
// Enter/Arrow). Nav keys are forwarded explicitly via forwardNavKey.
|
||||
event.stopPropagation();
|
||||
if (NAV_KEYS.has(event.key)) {
|
||||
event.preventDefault();
|
||||
forwardNavKey?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
const addFleetMouseDown = (event: React.MouseEvent) => {
|
||||
// Keep focus out of react-select's hidden input so the click fires on a
|
||||
// still-mounted menu.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const addFleetKeyDown = (event: React.KeyboardEvent) => {
|
||||
// Stop Enter/Space from bubbling to SelectContainer, which would treat
|
||||
// them as "select highlighted option" alongside the button's own click.
|
||||
// Escape/Tab/Arrow still bubble so react-select's close/focus work.
|
||||
// preventDefault on Enter — Fleet Button's handleKeyDown already
|
||||
// synthesizes onClick from Enter, so without preventDefault the browser
|
||||
// would ALSO synthesize a native click and fire onClickAddFleet twice.
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (event.key === " ") {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<components.Menu {...props}>
|
||||
{showSearch && (
|
||||
<div className={`${baseClass}__search-row`}>
|
||||
<div className={`${baseClass}__search-field`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
className={`${baseClass}__search-input`}
|
||||
value={searchQuery ?? ""}
|
||||
type="text"
|
||||
placeholder="Search fleets"
|
||||
aria-label="Search fleets"
|
||||
autoComplete="off"
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={onChangeSearchQuery}
|
||||
onClick={handleInputClick}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<Icon name="search" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{props.children}
|
||||
{showAddFleetButton && (
|
||||
<div
|
||||
className={`${baseClass}__add-fleet-footer`}
|
||||
onMouseDown={addFleetMouseDown}
|
||||
onKeyDown={addFleetKeyDown}
|
||||
>
|
||||
<Button
|
||||
variant="brand-inverse-icon"
|
||||
onClick={onClickAddFleet}
|
||||
iconStroke
|
||||
size="small"
|
||||
>
|
||||
<>
|
||||
Add fleet
|
||||
<Icon name="plus" color="core-fleet-green" />
|
||||
</>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</components.Menu>
|
||||
);
|
||||
};
|
||||
|
||||
// CustomMenuList only wraps the option list + a sticky scroll-fade at the
|
||||
// bottom. Because search + footer moved to CustomMenu, this element is the
|
||||
// full scroll container and its scrollbar spans only the options.
|
||||
const CustomMenuList = (props: MenuListProps<INumberDropdownOption, false>) => {
|
||||
const menuListElRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hasMoreBelow, setHasMoreBelow] = useState(false);
|
||||
|
||||
const updateHasMoreBelow = () => {
|
||||
const el = menuListElRef.current;
|
||||
if (!el) return;
|
||||
setHasMoreBelow(el.scrollHeight - el.scrollTop - el.clientHeight > 1);
|
||||
};
|
||||
|
||||
const setMenuListRef = (el: HTMLDivElement | null) => {
|
||||
menuListElRef.current = el;
|
||||
// Chain react-select's own innerRef so its scroll-to-highlighted-option
|
||||
// logic keeps working.
|
||||
props.innerRef?.(el as HTMLDivElement);
|
||||
};
|
||||
|
||||
// Measure whether the options list is scrollable after layout — the
|
||||
// ref-callback path fires before layout, so scrollHeight / clientHeight
|
||||
// can both read 0 on the first render and the fade wouldn't appear at
|
||||
// all. Keying on the child count avoids re-measuring on unrelated
|
||||
// renders (e.g. every keystroke inside the search input); the onScroll
|
||||
// handler covers user-driven position changes.
|
||||
const childCount = React.Children.count(props.children);
|
||||
useLayoutEffect(() => {
|
||||
updateHasMoreBelow();
|
||||
}, [childCount]);
|
||||
|
||||
// Chain react-select's own innerProps handlers before running our own —
|
||||
// otherwise our overrides silently drop whatever react-select (or a
|
||||
// future prop) provides.
|
||||
const originalOnScroll = props.innerProps?.onScroll;
|
||||
const originalOnMouseDown = props.innerProps?.onMouseDown;
|
||||
|
||||
return (
|
||||
<components.MenuList
|
||||
{...props}
|
||||
innerRef={setMenuListRef}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => {
|
||||
originalOnScroll?.(event);
|
||||
updateHasMoreBelow();
|
||||
},
|
||||
onMouseDown: (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
originalOnMouseDown?.(event);
|
||||
event.stopPropagation();
|
||||
},
|
||||
// Chrome (and other browsers with `keyboard-focusable-scrollers`
|
||||
// enabled) auto-focuses scrollable containers to allow keyboard
|
||||
// scrolling — that steals Tab from the search input and lands on
|
||||
// an outlined MenuList instead of the "Add fleet" button. tabIndex
|
||||
// -1 opts out; the search input + forwardNavKey bridge already
|
||||
// handle keyboard nav through options.
|
||||
tabIndex: -1,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
{/*
|
||||
Anchor is always rendered at 0 flow height so toggling the fade
|
||||
doesn't shift scrollHeight (which would clamp scrollTop and jump
|
||||
at the bottom). Gradient is a ::before pseudo, opacity-toggled
|
||||
via --visible.
|
||||
*/}
|
||||
<div
|
||||
className={classnames(`${baseClass}__scroll-fade`, {
|
||||
[`${baseClass}__scroll-fade--visible`]: hasMoreBelow,
|
||||
})}
|
||||
aria-hidden
|
||||
/>
|
||||
</components.MenuList>
|
||||
);
|
||||
};
|
||||
|
||||
const FleetsDropdown = ({
|
||||
currentUserFleets,
|
||||
selectedFleetId,
|
||||
includeAllFleets = true,
|
||||
includeUnassigned = false,
|
||||
isDisabled = false,
|
||||
onChange,
|
||||
onOpen,
|
||||
onClose,
|
||||
asFormField = false,
|
||||
}: IFleetsDropdownProps): JSX.Element => {
|
||||
const { isGlobalAdmin, config } = useContext(AppContext);
|
||||
|
||||
// Mirrors ManageFleetsPage: Primo + GitOps disable fleet creation. Also
|
||||
// hide when asFormField — clicking Add fleet would abandon in-progress
|
||||
// form input.
|
||||
const isPrimoModeEnabled = !!config?.partnerships?.enable_primo;
|
||||
const isGitOpsModeEnabled = !!(
|
||||
config?.gitops?.gitops_mode_enabled && config?.gitops?.repository_url
|
||||
);
|
||||
const isAddFleetDisabled = isPrimoModeEnabled || isGitOpsModeEnabled;
|
||||
const canAddFleet = !!isGlobalAdmin && !isAddFleetDisabled && !asFormField;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [menuIsOpen, setMenuIsOpen] = useState(false);
|
||||
const selectRef = useRef<SelectInstance<INumberDropdownOption, false>>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// react-select's SelectInstance doesn't type `inputRef` publicly.
|
||||
// Centralize the cast — if react-select ever renames this field, both
|
||||
// call sites (focus effect + forwardNavKey bridge) fail together. The
|
||||
// dev-only warning surfaces the loss of keyboard nav loudly on a
|
||||
// react-select upgrade instead of silently regressing.
|
||||
const getHiddenInput = () => {
|
||||
const ref = selectRef.current;
|
||||
if (!ref) return null;
|
||||
const input = ((ref as unknown) as {
|
||||
inputRef?: HTMLInputElement | null;
|
||||
}).inputRef;
|
||||
if (process.env.NODE_ENV !== "production" && input === undefined) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"FleetsDropdown: react-select's SelectInstance is missing the expected `inputRef` field. Keyboard nav may not work."
|
||||
);
|
||||
}
|
||||
return input ?? null;
|
||||
};
|
||||
|
||||
const fleetOptions: INumberDropdownOption[] = useMemo(
|
||||
() =>
|
||||
generateDropdownOptions(
|
||||
currentUserFleets,
|
||||
includeAllFleets,
|
||||
includeUnassigned
|
||||
),
|
||||
[currentUserFleets, includeAllFleets, includeUnassigned]
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() => filterOptionsBySearch(fleetOptions, searchQuery),
|
||||
[fleetOptions, searchQuery]
|
||||
);
|
||||
|
||||
const showSearch = fleetOptions.length >= MIN_ROWS_FOR_SEARCH;
|
||||
|
||||
const selectedValue = fleetOptions.find(
|
||||
(option) => selectedFleetId === option.value
|
||||
)
|
||||
? selectedFleetId
|
||||
: fleetOptions[0]?.value;
|
||||
|
||||
const selectedLabel =
|
||||
fleetOptions.find((o) => o.value === selectedValue)?.label ??
|
||||
APP_CONTEXT_ALL_TEAMS_SUMMARY.name;
|
||||
|
||||
// Close menu on click outside. Only attach the listener while the menu
|
||||
// is open. The transition effect below owns searchQuery clearing.
|
||||
useEffect(() => {
|
||||
if (!menuIsOpen) return undefined;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setMenuIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [menuIsOpen]);
|
||||
|
||||
// When the menu opens with no search input, focus react-select's hidden
|
||||
// input directly so Arrow / Enter / Escape drive option highlighting
|
||||
// natively — otherwise focus stays on the trigger and keydowns never
|
||||
// reach react-select. When search IS visible, the search input's native
|
||||
// `autoFocus` (in CustomMenu) handles focus, and the forwardNavKey
|
||||
// bridge routes nav keys through to react-select's hidden input.
|
||||
useEffect(() => {
|
||||
if (!menuIsOpen || showSearch) return;
|
||||
getHiddenInput()?.focus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [menuIsOpen, showSearch]);
|
||||
|
||||
// Fire onClose + clear searchQuery once per true -> false transition.
|
||||
// react-select's own onMenuClose only fires on its own closes; this
|
||||
// effect catches all paths (controlled and library-driven), so we can
|
||||
// stop repeating the same setSearchQuery("") + onClose at each close
|
||||
// origin. onClose is stashed in a ref so an inline parent callback
|
||||
// doesn't retrigger this effect.
|
||||
const onCloseRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
const wasOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (menuIsOpen) {
|
||||
wasOpenRef.current = true;
|
||||
} else if (wasOpenRef.current) {
|
||||
wasOpenRef.current = false;
|
||||
setSearchQuery("");
|
||||
onCloseRef.current?.();
|
||||
}
|
||||
}, [menuIsOpen]);
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (isDisabled) return;
|
||||
// Keep side effects out of the state updater — Strict Mode runs
|
||||
// updaters twice, which would double-fire onOpen. searchQuery clear
|
||||
// + onClose fire from the transition effect above.
|
||||
if (menuIsOpen) {
|
||||
setMenuIsOpen(false);
|
||||
} else {
|
||||
setMenuIsOpen(true);
|
||||
onOpen?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (newValue: INumberDropdownOption | null) => {
|
||||
if (!newValue) return;
|
||||
onChange(newValue.value);
|
||||
setMenuIsOpen(false);
|
||||
};
|
||||
|
||||
const onChangeSearchQuery = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(event.target.value);
|
||||
};
|
||||
|
||||
// Forwards a navigation key from the in-menu search input to react-select's
|
||||
// hidden input so its built-in keyDown handler runs (option highlighting,
|
||||
// selection, menu close).
|
||||
const forwardNavKey = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const input = getHiddenInput();
|
||||
if (!input) return;
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onClickAddFleet = () => {
|
||||
setMenuIsOpen(false);
|
||||
// TODO: hoist navigation to an onAddFleet callback prop so consumers own it.
|
||||
browserHistory.push(
|
||||
getPathWithQueryParams(PATHS.ADMIN_FLEETS, { create_fleet: "1" })
|
||||
);
|
||||
};
|
||||
|
||||
const wrapperClasses = classnames(`${baseClass}-wrapper`, {
|
||||
[`${baseClass}-wrapper--form-field`]: asFormField,
|
||||
[`${baseClass}-wrapper--disabled`]: isDisabled,
|
||||
});
|
||||
|
||||
const buttonClasses = classnames(`${baseClass}__button`, {
|
||||
[`${baseClass}__button--form-field`]: asFormField,
|
||||
});
|
||||
|
||||
const iconClasses = classnames(`${baseClass}__icon`, {
|
||||
[`${baseClass}__icon--open`]: menuIsOpen,
|
||||
});
|
||||
|
||||
// Menu + option styling only — the visible trigger is a real Fleet Button
|
||||
// above, and the react-select Control is hidden but kept in the DOM so its
|
||||
// hidden input can receive dispatched keydown events for nav keys.
|
||||
const customStyles: StylesConfig<INumberDropdownOption, false> = {
|
||||
control: () => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
overflow: "hidden",
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
}),
|
||||
menu: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
backgroundColor: COLORS["core-fleet-white"],
|
||||
boxShadow: `0 2px 6px rgba(0, 0, 0, 0.1), 0 0 0 1px ${COLORS["ui-fleet-black-10"]}`,
|
||||
borderRadius: "8px",
|
||||
// Page-overlay tier (99) per the 9/99/999 z-index convention.
|
||||
zIndex: 99,
|
||||
overflow: "hidden",
|
||||
border: 0,
|
||||
marginTop: PADDING["pad-xsmall"],
|
||||
width: 340,
|
||||
// Cap total menu height so the whole dropdown (search + options list +
|
||||
// footer) fits 14 options before the scrollbar engages — scroll first
|
||||
// shows at 15 rows per design. `min(...)` also clamps against the
|
||||
// viewport with ~32px breathing room — the design's "or when
|
||||
// restricted by page height" clause.
|
||||
maxHeight: "min(715px, calc(100vh - 32px))",
|
||||
// Menu owns the outer pad-medium inset; the search-row provides the
|
||||
// pad-medium gap below the input, and the footer's padding-top
|
||||
// provides the pad-medium above the "Add fleet" button. The options
|
||||
// list abuts the footer's border-top directly (no gap in between).
|
||||
padding: PADDING["pad-medium"],
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
animation: "fade-in 150ms ease-out",
|
||||
}),
|
||||
menuList: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
// The scrolling area. Fills remaining height inside the Menu flex
|
||||
// column so the scrollbar spans only the options — never behind the
|
||||
// (Menu-level) search-row or "Add fleet" footer.
|
||||
flex: "1 1 auto",
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
maxHeight: "none",
|
||||
// Menu owns the outer horizontal padding; a pad-small paddingBottom
|
||||
// gives the last option a bit of breathing room above the footer's
|
||||
// divider when the list is scrolled to the end.
|
||||
padding: `0 0 ${PADDING["pad-small"]}`,
|
||||
position: "relative",
|
||||
}),
|
||||
noOptionsMessage: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
padding: "10px 8px",
|
||||
fontSize: "13px",
|
||||
textAlign: "left",
|
||||
color: COLORS["ui-fleet-black-75"],
|
||||
}),
|
||||
option: (baseStyles, state) => ({
|
||||
...baseStyles,
|
||||
padding: "10px 8px",
|
||||
fontSize: "13px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: state.isFocused
|
||||
? COLORS["ui-fleet-black-5"]
|
||||
: "transparent",
|
||||
fontWeight: state.isSelected ? 600 : "normal",
|
||||
color: COLORS["core-fleet-black"],
|
||||
cursor: "pointer",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
"&:hover": {
|
||||
backgroundColor: COLORS["ui-fleet-black-5"],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={wrapperClasses} ref={wrapperRef}>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
type="button"
|
||||
onClick={toggleMenu}
|
||||
disabled={isDisabled}
|
||||
className={buttonClasses}
|
||||
ariaHasPopup="listbox"
|
||||
ariaExpanded={menuIsOpen}
|
||||
>
|
||||
<span className={`${baseClass}__button-label`}>{selectedLabel}</span>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
color={menuIsOpen ? "core-fleet-black" : "ui-fleet-black-75"}
|
||||
className={iconClasses}
|
||||
/>
|
||||
</Button>
|
||||
<Select<INumberDropdownOption, false>
|
||||
ref={selectRef}
|
||||
options={filteredOptions}
|
||||
value={fleetOptions.find((option) => option.value === selectedValue)}
|
||||
onChange={handleChange}
|
||||
isDisabled={isDisabled}
|
||||
isSearchable={false}
|
||||
// Tabbing through the open menu shouldn't select an option;
|
||||
// opt out of react-select's default "Tab selects focused option".
|
||||
tabSelectsValue={false}
|
||||
menuIsOpen={menuIsOpen}
|
||||
onMenuOpen={() => setMenuIsOpen(true)}
|
||||
onMenuClose={() => setMenuIsOpen(false)}
|
||||
styles={customStyles}
|
||||
components={{
|
||||
Menu: CustomMenu,
|
||||
MenuList: CustomMenuList,
|
||||
DropdownIndicator: () => null,
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
// Hidden input is never directly user-focused; it just receives
|
||||
// dispatched keydown events from the in-menu search input.
|
||||
tabIndex={-1}
|
||||
isOptionSelected={() => false}
|
||||
className={baseClass}
|
||||
classNamePrefix={baseClass}
|
||||
searchQuery={searchQuery}
|
||||
onChangeSearchQuery={onChangeSearchQuery}
|
||||
forwardNavKey={forwardNavKey}
|
||||
onClickAddFleet={onClickAddFleet}
|
||||
showAddFleetButton={canAddFleet}
|
||||
showSearch={showSearch}
|
||||
noOptionsMessage={() => "No matching fleets"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetsDropdown;
|
||||
@@ -0,0 +1,142 @@
|
||||
.fleet-dropdown-wrapper {
|
||||
// Shrink to fit the Button so the menu (positioned at left: 0 of the
|
||||
// react-select root, which spans the wrapper) doesn't overflow way off to
|
||||
// the side when the wrapper is dropped into a block-level page header.
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fleet-dropdown {
|
||||
&__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
|
||||
// Button renders label + chevron inside <div class="children-wrapper">.
|
||||
// The gap has to live there — not on the button itself.
|
||||
.children-wrapper {
|
||||
gap: $pad-small;
|
||||
}
|
||||
|
||||
&--form-field {
|
||||
padding: 8px 16px;
|
||||
background-color: $ui-light-grey;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-label {
|
||||
color: $core-fleet-black;
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: normal;
|
||||
max-width: 500px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__button--form-field &__button-label {
|
||||
font-size: $x-small;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
svg {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&--open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__search-row {
|
||||
// Sits at Menu level (above the scrolling MenuList). Menu owns the outer
|
||||
// pad-medium inset; padding-bottom here provides the pad-medium gap
|
||||
// between the search input and the first option below.
|
||||
padding-bottom: $pad-medium;
|
||||
background-color: $core-fleet-white;
|
||||
}
|
||||
|
||||
&__search-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
@include menu-search-input;
|
||||
}
|
||||
|
||||
// Even with tabIndex=-1 the MenuList can still receive programmatic focus
|
||||
// (e.g. click-to-scroll). Suppress the browser's default focus outline so
|
||||
// the scroll container never renders a stray blue ring.
|
||||
&__menu-list:focus,
|
||||
&__menu-list:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Scroll-fade anchor sits at the bottom of the options area with
|
||||
// zero flow height, so toggling the fade's visibility never changes
|
||||
// MenuList.scrollHeight (which would clamp scrollTop and jump at the
|
||||
// very bottom of the list). Negative bottom pulls the anchor past
|
||||
// MenuList's pad-small paddingBottom so the gradient sits flush
|
||||
// against the outer edge of the scroll area rather than 8px above it.
|
||||
//
|
||||
// The visible gradient is a ::before pseudo-element sized 35px,
|
||||
// toggled via opacity on the --visible modifier — that keeps the fade
|
||||
// out of layout entirely regardless of visibility.
|
||||
&__scroll-fade {
|
||||
position: sticky;
|
||||
bottom: -$pad-small;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 35px;
|
||||
// Fade from opaque Fleet-white at the bottom to transparent
|
||||
// Fleet-white at the top. Uses the shared -transparent variant
|
||||
// (same RGB as $core-fleet-white with alpha 0 in both light and
|
||||
// dark themes) so dark-mode variable swaps carry through with no
|
||||
// additional overrides — same pattern as the horizontal table
|
||||
// shadows.
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
$core-fleet-white 0%,
|
||||
$core-fleet-white-transparent 100%
|
||||
);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--visible::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-fleet-footer {
|
||||
// padding-top pushes "Add fleet" pad-medium below the border-top divider.
|
||||
padding-top: $pad-medium;
|
||||
background-color: $core-fleet-white;
|
||||
display: flex;
|
||||
border-top: solid 1px $ui-fleet-black-10;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./FleetsDropdown";
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { noop } from "lodash";
|
||||
|
||||
import TeamsDropdown from ".";
|
||||
|
||||
const meta: Meta<typeof TeamsDropdown> = {
|
||||
title: "Components/TeamsDropdown",
|
||||
component: TeamsDropdown,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ minHeight: 300 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
currentUserTeams: [
|
||||
{ id: 1, name: "Team 1" },
|
||||
{ id: 2, name: "Team 2" },
|
||||
],
|
||||
onChange: noop,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof TeamsDropdown>;
|
||||
|
||||
export const Basic: Story = {};
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { noop } from "lodash";
|
||||
// TODOL Replace renderWithAppContext with createCustomRenderer
|
||||
import { renderWithAppContext } from "test/test-utils";
|
||||
import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team";
|
||||
|
||||
import TeamsDropdown from "./TeamsDropdown";
|
||||
|
||||
describe("TeamsDropdown - component", () => {
|
||||
const USER_TEAMS = [
|
||||
{ id: -1, name: "All fleets" },
|
||||
{ id: 1, name: "Team 1" },
|
||||
{ id: 2, name: "Team 2" },
|
||||
];
|
||||
|
||||
it("renders the given selected team from selectedTeamId", () => {
|
||||
render(
|
||||
<TeamsDropdown
|
||||
currentUserTeams={USER_TEAMS}
|
||||
selectedTeamId={1}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const selectedTeam = screen.getByText("Team 1");
|
||||
expect(selectedTeam).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the first team option when includeAllTeams is false and when no selectedTeamId is given", () => {
|
||||
render(
|
||||
<TeamsDropdown
|
||||
currentUserTeams={USER_TEAMS}
|
||||
includeAllTeams={false}
|
||||
onChange={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const selectedTeam = screen.getByText("Team 1");
|
||||
expect(selectedTeam).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("user is on the global team", () => {
|
||||
const contextValue = {
|
||||
isOnGlobalTeam: true,
|
||||
};
|
||||
|
||||
it("renders 'All fleets' when no selectedTeamId is given", () => {
|
||||
renderWithAppContext(
|
||||
<TeamsDropdown currentUserTeams={USER_TEAMS} onChange={noop} />,
|
||||
{ contextValue }
|
||||
);
|
||||
|
||||
const selectedTeam = screen.getByText("All fleets");
|
||||
expect(selectedTeam).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the first team option when includeAllTeams is false and when no selectedTeamId is given", () => {
|
||||
renderWithAppContext(
|
||||
<TeamsDropdown
|
||||
currentUserTeams={USER_TEAMS}
|
||||
includeAllTeams={false}
|
||||
onChange={noop}
|
||||
/>,
|
||||
{ contextValue }
|
||||
);
|
||||
|
||||
const selectedTeam = screen.getByText("Team 1");
|
||||
expect(selectedTeam).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("user is not on the global team", () => {
|
||||
const contextValue = { isOnGlobalTeam: false };
|
||||
const filteredUserTeams = USER_TEAMS.filter(
|
||||
(t) => t.id > APP_CONTEXT_NO_TEAM_ID
|
||||
);
|
||||
|
||||
it("renders the first team when no selectedTeamId is given", () => {
|
||||
renderWithAppContext(
|
||||
<TeamsDropdown currentUserTeams={filteredUserTeams} onChange={noop} />,
|
||||
{ contextValue }
|
||||
);
|
||||
|
||||
expect(screen.getByText("Team 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,318 +0,0 @@
|
||||
import React, { useMemo } from "react";
|
||||
import Select, {
|
||||
components,
|
||||
DropdownIndicatorProps,
|
||||
GroupBase,
|
||||
OptionProps,
|
||||
StylesConfig,
|
||||
} from "react-select-5";
|
||||
|
||||
import { COLORS } from "styles/var/colors";
|
||||
import { PADDING } from "styles/var/padding";
|
||||
import { FONT_SIZES, FONT_WEIGHTS } from "styles/var/fonts";
|
||||
|
||||
import classnames from "classnames";
|
||||
|
||||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
import {
|
||||
APP_CONTEXT_ALL_TEAMS_SUMMARY,
|
||||
ITeamSummary,
|
||||
APP_CONTEXT_NO_TEAM_SUMMARY,
|
||||
} from "interfaces/team";
|
||||
|
||||
import Icon from "components/Icon";
|
||||
|
||||
export interface INumberDropdownOption extends Omit<IDropdownOption, "value"> {
|
||||
value: number; // Redefine the value property to be just number
|
||||
}
|
||||
|
||||
const generateDropdownOptions = (
|
||||
teams: ITeamSummary[] | undefined,
|
||||
includeAllTeams: boolean,
|
||||
includeNoTeams?: boolean
|
||||
): INumberDropdownOption[] => {
|
||||
if (!teams) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options: INumberDropdownOption[] = teams.map((team) => ({
|
||||
disabled: false,
|
||||
label: team.name,
|
||||
value: team.id,
|
||||
}));
|
||||
|
||||
const filtered = options.filter(
|
||||
(o) =>
|
||||
!(
|
||||
(o.label === APP_CONTEXT_NO_TEAM_SUMMARY.name && !includeNoTeams) ||
|
||||
(o.label === APP_CONTEXT_ALL_TEAMS_SUMMARY.name && !includeAllTeams)
|
||||
)
|
||||
);
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const getOptionBackgroundColor = (
|
||||
state: OptionProps<
|
||||
INumberDropdownOption,
|
||||
false,
|
||||
GroupBase<INumberDropdownOption>
|
||||
>
|
||||
) => {
|
||||
return state.isFocused ? COLORS["ui-fleet-black-5"] : "transparent";
|
||||
};
|
||||
|
||||
interface ITeamsDropdownProps {
|
||||
currentUserTeams: ITeamSummary[];
|
||||
selectedTeamId?: number;
|
||||
includeAllTeams?: boolean;
|
||||
includeNoTeams?: boolean;
|
||||
isDisabled?: boolean;
|
||||
onChange: (newSelectedValue: number) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
/** Indicates that this teams dropdown should be styled as a form field */
|
||||
asFormField?: boolean;
|
||||
}
|
||||
|
||||
const baseClass = "team-dropdown";
|
||||
|
||||
const TeamsDropdown = ({
|
||||
currentUserTeams,
|
||||
selectedTeamId,
|
||||
includeAllTeams = true,
|
||||
includeNoTeams = false,
|
||||
isDisabled = false,
|
||||
onChange,
|
||||
onOpen,
|
||||
onClose,
|
||||
asFormField = false,
|
||||
}: ITeamsDropdownProps): JSX.Element => {
|
||||
const teamOptions: INumberDropdownOption[] = useMemo(
|
||||
() =>
|
||||
generateDropdownOptions(
|
||||
currentUserTeams,
|
||||
includeAllTeams,
|
||||
includeNoTeams
|
||||
),
|
||||
[currentUserTeams, includeAllTeams, includeNoTeams]
|
||||
);
|
||||
|
||||
const selectedValue = teamOptions.find(
|
||||
(option) => selectedTeamId === option.value
|
||||
)
|
||||
? selectedTeamId
|
||||
: teamOptions[0]?.value;
|
||||
|
||||
const dropdownWrapperClasses = classnames(`${baseClass}-wrapper`, {
|
||||
disabled: isDisabled || undefined,
|
||||
});
|
||||
|
||||
const CustomDropdownIndicator = (
|
||||
props: DropdownIndicatorProps<
|
||||
INumberDropdownOption,
|
||||
false,
|
||||
GroupBase<INumberDropdownOption>
|
||||
>
|
||||
) => {
|
||||
const { isFocused, selectProps } = props;
|
||||
const color =
|
||||
isFocused || selectProps.menuIsOpen
|
||||
? "core-fleet-black"
|
||||
: "ui-fleet-black-75";
|
||||
|
||||
return (
|
||||
<components.DropdownIndicator {...props} className={baseClass}>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
color={color}
|
||||
className={`${baseClass}__icon`}
|
||||
/>
|
||||
</components.DropdownIndicator>
|
||||
);
|
||||
};
|
||||
|
||||
const [variableControlStyles, variableSingleValueStyles] = asFormField
|
||||
? [
|
||||
{
|
||||
padding: ".5rem 1rem",
|
||||
backgroundColor: COLORS["ui-light-grey"],
|
||||
},
|
||||
{},
|
||||
]
|
||||
: [
|
||||
{
|
||||
padding: "8px 0",
|
||||
backgroundColor: "initial",
|
||||
border: 0,
|
||||
},
|
||||
{
|
||||
fontSize: "24px",
|
||||
},
|
||||
];
|
||||
|
||||
// see https://react-select.com/styles#the-styles-prop
|
||||
const customStyles: StylesConfig<INumberDropdownOption, false> = {
|
||||
control: (baseStyles, state) => ({
|
||||
...baseStyles,
|
||||
...variableControlStyles,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
borderRadius: "4px",
|
||||
boxShadow: "none",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
boxShadow: "none",
|
||||
".team-dropdown__single-value": {
|
||||
color: COLORS["core-fleet-black"],
|
||||
},
|
||||
".team-dropdown__indicator path": {
|
||||
stroke: COLORS["ui-fleet-black-75-over"],
|
||||
},
|
||||
},
|
||||
// When tabbing
|
||||
// Relies on --is-focused for styling as &:focus-visible cannot be applied
|
||||
"&.team-dropdown__control--is-focused": {
|
||||
".team-dropdown__indicator path": {
|
||||
stroke: COLORS["ui-fleet-black-75-over"],
|
||||
},
|
||||
},
|
||||
...(state.isDisabled && {
|
||||
".team-dropdown__single-value": {
|
||||
color: COLORS["ui-fleet-black-50"],
|
||||
},
|
||||
".team-dropdown__indicator path": {
|
||||
stroke: COLORS["ui-fleet-black-50"],
|
||||
},
|
||||
}),
|
||||
// When clicking
|
||||
"&:active": {
|
||||
".team-dropdown__single-value": {
|
||||
color: COLORS["ui-fleet-black-75-down"],
|
||||
},
|
||||
".team-dropdown__indicator path": {
|
||||
stroke: COLORS["ui-fleet-black-75-down"],
|
||||
},
|
||||
},
|
||||
...(state.menuIsOpen && {
|
||||
".team-dropdown__indicator svg": {
|
||||
transform: "rotate(180deg)",
|
||||
transition: "transform 0.25s ease",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
singleValue: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
...variableSingleValueStyles,
|
||||
color: COLORS["core-fleet-black"],
|
||||
lineHeight: "normal",
|
||||
paddingLeft: 0,
|
||||
paddingRight: "8px",
|
||||
margin: 0,
|
||||
fontWeight: "600",
|
||||
// omit grid-column-end for automatic width
|
||||
gridArea: "1/1/2",
|
||||
}),
|
||||
dropdownIndicator: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
display: "flex",
|
||||
padding: "2px",
|
||||
margin: "0 5px",
|
||||
svg: {
|
||||
transition: "transform 0.25s ease",
|
||||
},
|
||||
}),
|
||||
menu: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
backgroundColor: COLORS["core-fleet-white"],
|
||||
boxShadow: `0 2px 6px rgba(0, 0, 0, 0.1), 0 0 0 1px ${COLORS["ui-fleet-black-10"]}`,
|
||||
borderRadius: "4px",
|
||||
zIndex: 6,
|
||||
overflow: "hidden",
|
||||
border: 0,
|
||||
marginTop: 0,
|
||||
minWidth: "330px",
|
||||
maxHeight: "none",
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
animation: "fade-in 150ms ease-out",
|
||||
}),
|
||||
// Placeholder is never shown on teams dropdown
|
||||
menuList: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
padding: PADDING["pad-small"],
|
||||
".team-dropdown__menu-notice--no-options": {
|
||||
textAlign: "left",
|
||||
color: COLORS["ui-fleet-black-50"],
|
||||
fontSize: FONT_SIZES["xx-small"],
|
||||
fontWeight: FONT_WEIGHTS.regular,
|
||||
},
|
||||
}),
|
||||
valueContainer: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
padding: 0,
|
||||
}),
|
||||
input: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
margin: 0,
|
||||
color: COLORS["core-fleet-black"],
|
||||
}),
|
||||
option: (baseStyles, state) => ({
|
||||
...baseStyles,
|
||||
padding: "10px 8px",
|
||||
fontSize: "13px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: getOptionBackgroundColor(state),
|
||||
fontWeight: state.isSelected ? "600" : "normal",
|
||||
color: COLORS["core-fleet-black"],
|
||||
"&:hover": {
|
||||
backgroundColor: state.isDisabled
|
||||
? "transparent"
|
||||
: COLORS["ui-fleet-black-5"],
|
||||
},
|
||||
"&:active": {
|
||||
backgroundColor: state.isDisabled
|
||||
? "transparent"
|
||||
: COLORS["ui-fleet-black-5"],
|
||||
},
|
||||
...(state.isDisabled && {
|
||||
color: COLORS["ui-fleet-black-50"],
|
||||
fontStyle: "italic",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={dropdownWrapperClasses}>
|
||||
<Select<INumberDropdownOption, false>
|
||||
options={teamOptions}
|
||||
placeholder="All fleets"
|
||||
onChange={(newValue) => {
|
||||
if (newValue) {
|
||||
onChange(newValue.value);
|
||||
}
|
||||
// If newValue is null or undefined, we don't call onChange
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
isSearchable
|
||||
noOptionsMessage={() => "No matching fleets"}
|
||||
styles={customStyles}
|
||||
components={{
|
||||
DropdownIndicator: CustomDropdownIndicator,
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
value={teamOptions.find((option) => option.value === selectedValue)}
|
||||
isOptionSelected={() => false} // Hides any styling on selected option
|
||||
className={baseClass}
|
||||
classNamePrefix={baseClass}
|
||||
onMenuOpen={onOpen}
|
||||
onMenuClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsDropdown;
|
||||
@@ -1 +0,0 @@
|
||||
// All styling in customStyles part of react-select-5
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./TeamsDropdown";
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
|
||||
import { ITeamSummary } from "interfaces/team";
|
||||
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
|
||||
interface ITeamsHeader {
|
||||
isOnGlobalTeam?: boolean;
|
||||
@@ -20,11 +20,11 @@ const TeamsHeader = ({
|
||||
if (userTeams) {
|
||||
if (userTeams.length > 1 || isOnGlobalTeam) {
|
||||
return (
|
||||
<TeamsDropdown
|
||||
currentUserTeams={userTeams}
|
||||
selectedTeamId={currentTeamId}
|
||||
<FleetsDropdown
|
||||
currentUserFleets={userTeams}
|
||||
selectedFleetId={currentTeamId}
|
||||
onChange={onTeamChange}
|
||||
includeNoTeams
|
||||
includeUnassigned
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@ There's also a `PRIMO_TOOLTIP` constant in `utilities/constants.tsx` for disable
|
||||
#### What it affects
|
||||
|
||||
- **"Create fleet" button**: disabled on ManageFleetsPage
|
||||
- **Fleet switcher**: hidden (both the page `TeamsDropdown` header and the command palette fleet picker)
|
||||
- **Fleet switcher**: hidden (both the page `FleetsDropdown` header and the command palette fleet picker)
|
||||
- **Selected fleet**: `useTeamIdParam` defaults to "Unassigned" instead of "All fleets"
|
||||
- **Empty states**: skip the fleet-scoped copy premium normally shows, falling back to the generic header that free tier already uses (e.g., "No policies yet" instead of "No policies for this fleet" or "No policies apply to all fleets")
|
||||
- **User form**: fleets dropdown disabled
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
|
||||
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||||
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
import Spinner from "components/Spinner";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import { SingleValue } from "react-select-5";
|
||||
@@ -907,9 +907,9 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
|
||||
if (userTeams) {
|
||||
if (userTeams.length > 1 || isOnGlobalTeam) {
|
||||
return (
|
||||
<TeamsDropdown
|
||||
selectedTeamId={currentTeamId}
|
||||
currentUserTeams={userTeams}
|
||||
<FleetsDropdown
|
||||
selectedFleetId={currentTeamId}
|
||||
currentUserFleets={userTeams}
|
||||
onChange={handleTeamChange}
|
||||
/>
|
||||
);
|
||||
|
||||
+1
-30
@@ -13,36 +13,7 @@
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
width: 100%;
|
||||
line-height: $line-height;
|
||||
background-color: $core-fleet-white;
|
||||
border: solid 1px $ui-fleet-black-10;
|
||||
border-radius: $border-radius;
|
||||
font-size: $small;
|
||||
padding: 9.5px 12px 9.5px 36px;
|
||||
color: $core-fleet-blue;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: $x-small;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
|
||||
&::placeholder {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
border-color: $ui-fleet-black-75;
|
||||
|
||||
+ .icon {
|
||||
svg {
|
||||
path {
|
||||
fill: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@include menu-search-input;
|
||||
|
||||
&--disabled {
|
||||
color: $ui-fleet-black-50;
|
||||
|
||||
@@ -10,7 +10,7 @@ import useTeamIdParam from "hooks/useTeamIdParam";
|
||||
import TabNav from "components/TabNav";
|
||||
import TabText from "components/TabText";
|
||||
import MainContent from "components/MainContent";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
import { parseOSUpdatesCurrentVersionsQueryParams } from "./OSUpdates/components/CurrentVersionSection/CurrentVersionSection";
|
||||
|
||||
interface IControlsSubNavItem {
|
||||
@@ -217,12 +217,12 @@ const ManageControlsPage = ({
|
||||
if (isPremiumTier && !config?.partnerships?.enable_primo && userTeams) {
|
||||
if (userTeams.length > 1 || isOnGlobalTeam) {
|
||||
return (
|
||||
<TeamsDropdown
|
||||
currentUserTeams={userTeams}
|
||||
selectedTeamId={currentTeamId}
|
||||
<FleetsDropdown
|
||||
currentUserFleets={userTeams}
|
||||
selectedFleetId={currentTeamId}
|
||||
onChange={handleTeamChange}
|
||||
includeAllTeams={false}
|
||||
includeNoTeams
|
||||
includeAllFleets={false}
|
||||
includeUnassigned
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -89,10 +89,10 @@ describe("SelfServiceCategoriesPage", () => {
|
||||
expect(
|
||||
screen.getByText("This feature is included in Fleet Premium.")
|
||||
).toBeInTheDocument();
|
||||
// Fleet Free has no concept of teams — the dropdown must be hidden, and
|
||||
// Fleet Free has no concept of fleets — the dropdown must be hidden, and
|
||||
// a static page title takes its place.
|
||||
expect(
|
||||
container.querySelector(".team-dropdown-wrapper")
|
||||
container.querySelector(".fleet-dropdown-wrapper")
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Self-service categories" })
|
||||
|
||||
+6
-6
@@ -22,7 +22,7 @@ import MainContent from "components/MainContent";
|
||||
import PageDescription from "components/PageDescription";
|
||||
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
|
||||
import Spinner from "components/Spinner";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
import TooltipTruncatedText from "components/TooltipTruncatedText";
|
||||
import UploadList from "components/UploadList";
|
||||
|
||||
@@ -143,12 +143,12 @@ const SelfServiceCategoriesPage = ({
|
||||
<BackButton text="Back to software library" path={backToLibraryPath} />
|
||||
{isPremiumTier && !isPrimoMode ? (
|
||||
<div className={`${baseClass}__fleet-row`}>
|
||||
<TeamsDropdown
|
||||
currentUserTeams={userTeams ?? []}
|
||||
selectedTeamId={currentTeamId}
|
||||
<FleetsDropdown
|
||||
currentUserFleets={userTeams ?? []}
|
||||
selectedFleetId={currentTeamId}
|
||||
onChange={handleTeamChange}
|
||||
includeAllTeams={false}
|
||||
includeNoTeams
|
||||
includeAllFleets={false}
|
||||
includeUnassigned
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
&__fleet-row {
|
||||
align-self: stretch;
|
||||
|
||||
.team-dropdown-wrapper {
|
||||
.fleet-dropdown-wrapper {
|
||||
@include normalize-team-header;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.software-os-details-page {
|
||||
@include vertical-page-layout;
|
||||
|
||||
.team-dropdown-wrapper {
|
||||
.fleet-dropdown-wrapper {
|
||||
@include normalize-team-header;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.software-title-details-page {
|
||||
@include vertical-page-layout;
|
||||
|
||||
.team-dropdown-wrapper {
|
||||
.fleet-dropdown-wrapper {
|
||||
@include normalize-team-header;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.software-version-details-page {
|
||||
@include vertical-page-layout;
|
||||
|
||||
.team-dropdown-wrapper {
|
||||
.fleet-dropdown-wrapper {
|
||||
@include normalize-team-header;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.software-vulnerability-details-page {
|
||||
@include vertical-page-layout;
|
||||
|
||||
.team-dropdown-wrapper {
|
||||
.fleet-dropdown-wrapper {
|
||||
@include normalize-team-header;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import Spinner from "components/Spinner";
|
||||
import TabNav from "components/TabNav";
|
||||
import TabText from "components/TabText";
|
||||
import BackButton from "components/BackButton";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
import MainContent from "components/MainContent";
|
||||
import { notify } from "components/ToastNotification";
|
||||
import DeleteFleetModal from "../components/DeleteFleetModal";
|
||||
@@ -402,11 +402,11 @@ const TeamDetailsWrapper = ({
|
||||
{userTeams?.length === 1 ? (
|
||||
<h1>{currentTeamDetails.name}</h1>
|
||||
) : (
|
||||
<TeamsDropdown
|
||||
selectedTeamId={currentTeamId}
|
||||
currentUserTeams={userTeams || []}
|
||||
<FleetsDropdown
|
||||
selectedFleetId={currentTeamId}
|
||||
currentUserFleets={userTeams || []}
|
||||
isDisabled={isLoadingTeams}
|
||||
includeAllTeams={false}
|
||||
includeAllFleets={false}
|
||||
onChange={handleTeamChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||||
import TableCount from "components/TableContainer/TableCount";
|
||||
import DataError from "components/DataError";
|
||||
import { IActionButtonProps } from "components/TableContainer/DataTable/ActionButton/ActionButton";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
import Spinner from "components/Spinner";
|
||||
import MainContent from "components/MainContent";
|
||||
import EmptyState from "components/EmptyState";
|
||||
@@ -1594,11 +1594,11 @@ const ManageHostsPage = ({
|
||||
if (isPremiumTier && !isPrimoMode && userTeams) {
|
||||
if (userTeams.length > 1 || isOnGlobalTeam) {
|
||||
return (
|
||||
<TeamsDropdown
|
||||
currentUserTeams={userTeams || []}
|
||||
selectedTeamId={currentTeamId}
|
||||
<FleetsDropdown
|
||||
currentUserFleets={userTeams}
|
||||
selectedFleetId={currentTeamId}
|
||||
onChange={onTeamChange}
|
||||
includeNoTeams
|
||||
includeUnassigned
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-29
@@ -61,34 +61,6 @@
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
width: 100%;
|
||||
line-height: $line-height;
|
||||
background-color: $core-fleet-white;
|
||||
border: solid 1px $ui-fleet-black-10;
|
||||
border-radius: $border-radius;
|
||||
padding: 9.5px 12px 9.5px 36px;
|
||||
color: $core-fleet-black;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: $x-small;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
|
||||
&::placeholder {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
border-color: $ui-fleet-black-75;
|
||||
|
||||
+ .icon {
|
||||
svg {
|
||||
path {
|
||||
fill: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@include menu-search-input;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ import { SingleValue } from "react-select-5";
|
||||
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
|
||||
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
|
||||
import Spinner from "components/Spinner";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
import TableDataError from "components/DataError";
|
||||
import MainContent from "components/MainContent";
|
||||
import PageDescription from "components/PageDescription";
|
||||
@@ -963,11 +963,11 @@ const ManagePolicyPage = ({
|
||||
if (isPremiumTier && !isPrimoMode) {
|
||||
if ((userTeams && userTeams.length > 1) || isOnGlobalTeam) {
|
||||
return (
|
||||
<TeamsDropdown
|
||||
currentUserTeams={userTeams || []}
|
||||
selectedTeamId={currentTeamId}
|
||||
<FleetsDropdown
|
||||
currentUserFleets={userTeams || []}
|
||||
selectedFleetId={currentTeamId}
|
||||
onChange={onTeamChange}
|
||||
includeNoTeams
|
||||
includeUnassigned
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import Button from "components/buttons/Button";
|
||||
import AutomationsButton from "components/buttons/AutomationsButton";
|
||||
import TableDataError from "components/DataError";
|
||||
import MainContent from "components/MainContent";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
import useTeamIdParam from "hooks/useTeamIdParam";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import QueriesTable from "./components/QueriesTable";
|
||||
@@ -282,9 +282,9 @@ const ManageQueriesPage = ({
|
||||
if (isPremiumTier && userTeams && !config?.partnerships?.enable_primo) {
|
||||
if (userTeams.length > 1 || isOnGlobalTeam) {
|
||||
return (
|
||||
<TeamsDropdown
|
||||
currentUserTeams={userTeams}
|
||||
selectedTeamId={currentTeamId}
|
||||
<FleetsDropdown
|
||||
currentUserFleets={userTeams}
|
||||
selectedFleetId={currentTeamId}
|
||||
onChange={onTeamChange}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
import FleetsDropdown from "components/FleetsDropdown";
|
||||
import { useTeamIdParam } from "hooks/useTeamIdParam";
|
||||
|
||||
const baseClass = "save-as-new-query-modal";
|
||||
@@ -201,10 +201,10 @@ const SaveAsNewQueryModal = ({
|
||||
{isPremiumTier && (userTeams?.length || 0) > 1 && (
|
||||
<div className="form-field">
|
||||
<div className="form-field__label">Fleet</div>
|
||||
<TeamsDropdown
|
||||
<FleetsDropdown
|
||||
asFormField
|
||||
currentUserTeams={userTeams || []}
|
||||
selectedTeamId={formData.team.id}
|
||||
currentUserFleets={userTeams || []}
|
||||
selectedFleetId={formData.team.id}
|
||||
onChange={onTeamChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -475,3 +475,36 @@ $max-width: 2560px;
|
||||
background-repeat: no-repeat;
|
||||
background-color: $core-fleet-white; // area below gradient — transitionable
|
||||
}
|
||||
|
||||
// Shared styling for a search input rendered inside a dropdown menu (icon on
|
||||
// the left, 36px tall, Fleet-black-10 border). Used by FleetsDropdown,
|
||||
// CategoryFilter, and ActivityTypeDropdown.
|
||||
// TODO: extend Fleet's SearchField / InputFieldWithIcon to cover this
|
||||
// pattern — out of scope for the FleetsDropdown rework.
|
||||
@mixin menu-search-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
padding: 9.5px 12px 9.5px 36px;
|
||||
line-height: $line-height;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: $x-small;
|
||||
color: $core-fleet-black;
|
||||
background-color: $core-fleet-white;
|
||||
border: solid 1px $ui-fleet-black-10;
|
||||
border-radius: $border-radius;
|
||||
|
||||
&::placeholder {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $ui-fleet-black-75;
|
||||
|
||||
+ .icon svg path {
|
||||
fill: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user