Fleet UI: Manage host page empty state updates (#44880)
This commit is contained in:
@@ -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 they’re 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 (
|
||||
<>
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user