Fleet UI: Manage host page empty state updates (#44880)

This commit is contained in:
RachelElysia
2026-05-08 09:27:11 -04:00
committed by GitHub
parent 258159ee63
commit 5a972b38e8
4 changed files with 294 additions and 32 deletions
@@ -16,12 +16,18 @@ export const MANAGE_HOSTS_PAGE_FILTER_KEYS = [
"mdm_enrollment_status",
"os_name",
"os_version",
"os_version_id",
"vulnerability",
"munki_issue_id",
"low_disk_space",
HOSTS_QUERY_PARAMS.OS_SETTINGS,
HOSTS_QUERY_PARAMS.DISK_ENCRYPTION,
"macos_bootstrap_package",
"bootstrap_package",
"profile_status",
"profile_uuid",
"dep_profile_error",
"dep_assign_profile_response",
HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_STATUS,
HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_ID,
] as const;
@@ -0,0 +1,227 @@
import React from "react";
import { screen, within } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { createCustomRenderer, baseUrl } from "test/test-utils";
import mockServer from "test/mock-server";
import createMockConfig from "__mocks__/configMock";
import createMockUser from "__mocks__/userMock";
import ManageHostsPage from "./ManageHostsPage";
const mockAppContext = {
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isOnlyObserver: false,
isPremiumTier: false,
isFreeTier: true,
currentUser: createMockUser({ global_role: "admin" }),
config: createMockConfig(),
setFilteredHostsPath: jest.fn(),
setFilteredPoliciesPath: jest.fn(),
setFilteredQueriesPath: jest.fn(),
setFilteredSoftwarePath: jest.fn(),
};
// Handlers
const getConfigHandler = () =>
http.get(baseUrl("/config"), () => {
return HttpResponse.json(createMockConfig());
});
const getLabelsHandler = () =>
http.get(baseUrl("/labels"), () => {
return HttpResponse.json({ labels: [] });
});
const getHostsHandler = (hosts: Record<string, unknown>[] = []) =>
http.get(baseUrl("/hosts"), () => {
return HttpResponse.json({ hosts });
});
const getHostsCountHandler = (count: number) =>
http.get(baseUrl("/hosts/count"), () => {
return HttpResponse.json({ count });
});
const getGlobalEnrollSecretsHandler = () =>
http.get(baseUrl("/spec/enroll_secret"), () => {
return HttpResponse.json({
spec: { secrets: [{ secret: "test-secret" }] },
});
});
const getMeHandler = () =>
http.get(baseUrl("/me"), () => {
return HttpResponse.json({
user: createMockUser({ global_role: "admin" }),
});
});
// Mock props
interface IMockPropsOverrides {
location?: {
pathname?: string;
search?: string;
hash?: string;
query?: Record<string, string>;
};
[key: string]: unknown;
}
const createMockProps = (overrides?: IMockPropsOverrides) => ({
route: { path: "hosts/manage" },
router: {
push: jest.fn(),
replace: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
go: jest.fn(),
setRouteLeaveHook: jest.fn(),
isActive: jest.fn(),
createHref: jest.fn(),
createPath: jest.fn(),
},
params: {},
location: {
pathname: "/hosts/manage",
search: "",
hash: "",
query: {},
...overrides?.location,
},
...overrides,
});
const setupHandlers = (
hostCount: number,
hosts: Record<string, unknown>[] = []
) => {
mockServer.use(
getConfigHandler(),
getLabelsHandler(),
getHostsHandler(hosts),
getHostsCountHandler(hostCount),
getGlobalEnrollSecretsHandler(),
getMeHandler()
);
};
describe("ManageHostsPage", () => {
it("renders truly empty state with disabled controls", async () => {
setupHandlers(0);
const render = createCustomRenderer({
withBackendMock: true,
context: { app: mockAppContext },
});
render(<ManageHostsPage {...(createMockProps() as any)} />);
// Empty state copy
expect(await screen.findByText("No hosts")).toBeInTheDocument();
expect(
screen.getByText(
/Fleet refers to computers, servers, and mobile devices as hosts/
)
).toBeInTheDocument();
// Host count
expect(screen.getByText("0 hosts")).toBeInTheDocument();
// Controls are disabled
expect(
screen.getByRole("button", { name: /export hosts/i })
).toBeDisabled();
expect(
screen.getByRole("button", { name: /edit columns/i })
).toBeDisabled();
expect(screen.getByPlaceholderText(/search name/i)).toBeDisabled();
// Add hosts button still visible in the page header
const headerWrap = screen
.getByText("Manage enroll secret")
.closest(".manage-hosts__button-wrap");
expect(
within(headerWrap as HTMLElement).getByText("Add hosts")
).toBeInTheDocument();
});
it("renders filtered empty state with enabled controls", async () => {
setupHandlers(0);
const render = createCustomRenderer({
withBackendMock: true,
context: { app: mockAppContext },
});
const props = createMockProps({
location: {
pathname: "/hosts/manage",
search: "?query=nonexistent",
hash: "",
query: { query: "nonexistent" },
},
});
render(<ManageHostsPage {...(props as any)} />);
// Filtered empty state copy
expect(
await screen.findByText("No hosts match your filters")
).toBeInTheDocument();
expect(
screen.getByText(
/Recently enrolled hosts will appear here after their first check-in/
)
).toBeInTheDocument();
// Controls are NOT disabled
expect(screen.getByPlaceholderText(/search name/i)).not.toBeDisabled();
expect(
screen.getByRole("button", { name: /edit columns/i })
).not.toBeDisabled();
});
it("renders populated state with enabled controls", async () => {
const mockHost = {
id: 1,
hostname: "test-host",
display_name: "test-host",
display_text: "test-host",
status: "online",
platform: "darwin",
os_version: "macOS 14.0",
team_id: null,
team_name: null,
primary_ip: "192.168.1.1",
primary_mac: "00:00:00:00:00:00",
seen_time: "2024-01-01T00:00:00Z",
hardware_serial: "ABC123",
computer_name: "test-host",
cpu_type: "x86_64",
memory: 8000000000,
issues: { total_issues_count: 0, failing_policies_count: 0 },
};
setupHandlers(1, [mockHost]);
const render = createCustomRenderer({
withBackendMock: true,
context: { app: mockAppContext },
});
render(<ManageHostsPage {...(createMockProps() as any)} />);
expect(await screen.findByText("1 host")).toBeInTheDocument();
// Controls are NOT disabled
expect(screen.getByPlaceholderText(/search name/i)).not.toBeDisabled();
expect(
screen.getByRole("button", { name: /export hosts/i })
).not.toBeDisabled();
expect(
screen.getByRole("button", { name: /edit columns/i })
).not.toBeDisabled();
});
});
@@ -1650,15 +1650,30 @@ const ManageHostsPage = ({
}
};
// TODO: try to reduce overlap between maybeEmptyHosts and includesFilterQueryParam
const maybeEmptyHosts =
totalFilteredHostsCount === 0 && searchQuery === "" && !labelID && !status;
const includesFilterQueryParam = MANAGE_HOSTS_PAGE_FILTER_KEYS.some(
(filter) =>
filter !== "fleet_id" &&
typeof queryParams === "object" &&
filter in queryParams // TODO: replace this with `Object.hasOwn(queryParams, filter)` when we upgrade to es2022
);
// No hosts enrolled at all, no filters active
const isTrulyEmpty = maybeEmptyHosts && !includesFilterQueryParam;
const renderHostCount = useCallback(() => {
return (
<>
<TableCount name="hosts" count={totalFilteredHostsCount} />
{!!totalFilteredHostsCount && (
{(!!totalFilteredHostsCount || isTrulyEmpty) && (
<Button
className={`${baseClass}__export-btn`}
onClick={onExportHostsResults}
variant="inverse"
disabled={isTrulyEmpty}
>
<>
Export hosts
@@ -1668,7 +1683,7 @@ const ManageHostsPage = ({
)}
</>
);
}, [isLoadingHostsCount, totalFilteredHostsCount]);
}, [isLoadingHostsCount, totalFilteredHostsCount, isTrulyEmpty]);
const renderCustomControls = () => {
// we filter out the status labels as we dont want to display them in the label
@@ -1688,6 +1703,7 @@ const ManageHostsPage = ({
options={hostSelectStatuses(isPremiumTier || false)}
onChange={handleStatusDropdownChange}
variant="table-filter"
isDisabled={isTrulyEmpty}
/>
<LabelFilterSelect
className={`${baseClass}__label-filter-dropdown`}
@@ -1697,22 +1713,12 @@ const ManageHostsPage = ({
onChange={handleLabelChange}
onAddLabel={onAddLabelClick}
isLoading={isLoadingLabels}
isDisabled={isTrulyEmpty}
/>
</div>
);
};
// TODO: try to reduce overlap between maybeEmptyHosts and includesFilterQueryParam
const maybeEmptyHosts =
totalFilteredHostsCount === 0 && searchQuery === "" && !labelID && !status;
const includesFilterQueryParam = MANAGE_HOSTS_PAGE_FILTER_KEYS.some(
(filter) =>
filter !== "fleet_id" &&
typeof queryParams === "object" &&
filter in queryParams // TODO: replace this with `Object.hasOwn(queryParams, filter)` when we upgrade to es2022
);
// Ensures rendering table/pills simultaneously when all API calls are done
const isLoading =
isLoadingHosts ||
@@ -1730,7 +1736,7 @@ const ManageHostsPage = ({
if (hasErrors) {
return <DataError verticalPaddingSize="pad-xxxlarge" />;
}
if (maybeEmptyHosts) {
if (maybeEmptyHosts && !isTrulyEmpty) {
const emptyState = () => {
const emptyHosts: IEmptyStateProps = {
header: "Hosts will show up here once theyre added to Fleet",
@@ -1741,15 +1747,6 @@ const ManageHostsPage = ({
emptyHosts.header = "No hosts match the current criteria";
emptyHosts.info =
"Expecting to see new hosts? Try again soon as the system catches up.";
} else if (canEnrollHosts) {
emptyHosts.header = "Add your hosts to Fleet";
emptyHosts.info =
"Generate Fleet's agent (fleetd) to add your own hosts.";
emptyHosts.primaryButton = (
<Button onClick={toggleAddHostsModal} type="button">
Add hosts
</Button>
);
}
return emptyHosts;
};
@@ -1826,14 +1823,40 @@ const ManageHostsPage = ({
const emptyState = () => {
const emptyHosts: IEmptyStateProps = {
header: "No hosts match the current criteria",
header: "No hosts match your filters",
info:
"Expecting to see new hosts? Try again soon as the system catches up.",
"Recently enrolled hosts will appear here after their first check-in.",
primaryButton: canEnrollHosts ? (
<Button onClick={toggleAddHostsModal} type="button">
Add hosts
</Button>
) : undefined,
};
if (isLastPage) {
if (isTrulyEmpty) {
emptyHosts.header = "No hosts";
if (canEnrollHosts) {
emptyHosts.info = (
<>
Fleet refers to computers, servers, and mobile devices as hosts.
<br />
Add a host to start seeing data.
</>
);
emptyHosts.primaryButton = (
<Button onClick={toggleAddHostsModal} type="button">
Add hosts
</Button>
);
} else {
emptyHosts.info =
"Fleet refers to computers, servers, and mobile devices as hosts.";
emptyHosts.primaryButton = undefined;
}
} else if (isLastPage) {
emptyHosts.header = "No more hosts to display";
emptyHosts.info =
"Expecting to see more hosts? Try again soon as the system catches up.";
emptyHosts.primaryButton = undefined;
}
return emptyHosts;
@@ -1896,10 +1919,16 @@ const ManageHostsPage = ({
showMarkAllPages={!unsupportedFilter} // Shortterm fix for #17257
isAllPagesSelected={isAllMatchingHostsSelected}
searchable
disableSearch={isTrulyEmpty}
renderCount={renderHostCount}
searchToolTipText={HOSTS_SEARCH_BOX_TOOLTIP}
disableActionButton={isTrulyEmpty}
emptyComponent={() => (
<EmptyState header={emptyState().header} info={emptyState().info} />
<EmptyState
header={emptyState().header}
info={emptyState().info}
primaryButton={emptyState().primaryButton}
/>
)}
customControl={renderCustomControls}
onQueryChange={onTableQueryChange}
@@ -1944,10 +1973,7 @@ const ManageHostsPage = ({
);
};
const showAddHostsButton =
canEnrollHosts &&
!hasErrors &&
(!maybeEmptyHosts || includesFilterQueryParam);
const showAddHostsButton = canEnrollHosts && !hasErrors;
return (
<>
@@ -92,6 +92,7 @@ interface ILabelFilterSelectProps {
onChange: (labelId: ILabel) => void;
onAddLabel: () => void;
isLoading?: boolean;
isDisabled?: boolean;
}
const LabelFilterSelect = ({
@@ -102,6 +103,7 @@ const LabelFilterSelect = ({
onChange,
onAddLabel,
isLoading = false,
isDisabled = false,
}: ILabelFilterSelectProps) => {
const [labelQuery, setLabelQuery] = useState("");
@@ -196,7 +198,7 @@ const LabelFilterSelect = ({
};
return (
<div className={classes} onClick={toggleMenu}>
<div className={classes} onClick={isDisabled ? undefined : toggleMenu}>
<Select<ILabel | IEmptyOption, false, IGroupOption>
ref={selectRef}
name="input-filter-select"
@@ -205,6 +207,7 @@ const LabelFilterSelect = ({
placeholder="Filter by platform or label"
value={selectedLabel}
isSearchable={false}
isDisabled={isDisabled}
components={{
GroupHeading: CustomLabelGroupHeading,
DropdownIndicator: CustomDropdownIndicator,