Fleet UI: Update empty states in software pages (#44979)

This commit is contained in:
RachelElysia
2026-05-13 15:43:48 -04:00
committed by GitHub
parent 64f5660a95
commit ba9283fd44
13 changed files with 126 additions and 83 deletions
@@ -49,7 +49,7 @@
padding-left: 36px;
}
::placeholder {
&::placeholder {
color: $ui-fleet-black-50;
}
@@ -58,8 +58,12 @@
}
&:disabled {
color: $ui-fleet-black-50;
color: $ui-fleet-black-33;
cursor: not-allowed;
&::placeholder {
color: $ui-fleet-black-33;
}
}
&--error {
@@ -72,10 +76,10 @@
&__input-wrapper--disabled {
.input-icon-field__icon {
color: $ui-fleet-black-50;
svg path {
fill: $ui-fleet-black-50;
svg {
path {
fill: $ui-fleet-black-33;
}
}
}
}
@@ -60,7 +60,7 @@ describe("SoftwareAppStoreVpp", () => {
});
expect(
screen.getByText("To add App Store apps, first enable VPP.")
screen.getByText("Enable VPP to add App Store apps (MDM required).")
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Enable VPP" })
@@ -47,7 +47,7 @@ const EnableVppMessage = ({
header="Volume Purchasing Program (VPP) isn't enabled"
info={
isGlobalAdmin
? "To add App Store apps, first enable VPP."
? "Enable VPP to add App Store apps (MDM required)."
: "To add App Store apps, ask your admin to enable VPP."
}
primaryButton={
@@ -97,15 +97,19 @@ describe("Software inventory table", () => {
expect(screen.getByText("No software detected")).toBeInTheDocument();
expect(
screen.getByText("Expecting to see software? Check back later.")
screen.getByText(
"Recently installed software will appear after the next scheduled check-in."
)
).toBeInTheDocument();
expect(screen.getByText("0 items")).toBeInTheDocument();
expect(screen.queryByText("Search")).toBeNull();
expect(screen.queryByText("Updated")).toBeNull();
expect(screen.queryByText("Add filters")).toBeNull();
expect(
screen.getByPlaceholderText("Search by name or vulnerability (CVE)")
).toBeDisabled();
expect(screen.getByRole("button", { name: /add filters/i })).toBeDisabled();
expect(screen.getByText("Show versions")).toBeInTheDocument();
});
it("Renders the page-wide empty state hiding vulnerability filtering when search query does not exist but versions toggle is applied", () => {
it("Keeps controls enabled when versions toggle is applied but no data so users can toggle back", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -144,10 +148,16 @@ describe("Software inventory table", () => {
expect(screen.getByText("No software detected")).toBeInTheDocument();
expect(
screen.getByText("Expecting to see software? Check back later.")
screen.getByText(
"Recently installed software will appear after the next scheduled check-in."
)
).toBeInTheDocument();
expect(screen.queryByText("Search")).toBeNull();
expect(screen.queryByText("Add filters")).toBeNull();
// Controls stay enabled so users can toggle back to the titles view,
// which may have installers even when the versions view is empty.
expect(
screen.getByPlaceholderText("Search by name or vulnerability (CVE)")
).toBeEnabled();
expect(screen.getByRole("button", { name: /add filters/i })).toBeEnabled();
});
it("Renders the empty search state and vulnerability filtering when search query does not exist but vulnerability filter is applied", () => {
@@ -171,14 +171,16 @@ const SoftwareTable = ({
return generateTableConfig(router, teamId);
}, [generateTableConfig, data, router, teamId]);
// Determines if a user should be able to filter or search in the table
const hasData = tableData && tableData.length > 0;
const hasQuery = query !== "";
const vulnFilterDetails = getVulnFilterRenderDetails(vulnFilters);
const hasVulnFilters = vulnFilterDetails.filterCount > 0;
const showFilterHeaders =
isSoftwareEnabled && (hasData || hasQuery || hasVulnFilters);
// Include showVersions — the titles view can have installers even when
// the versions view is empty, so the toggle should stay interactive.
const isTrulyEmpty =
!hasData && !hasQuery && !hasVulnFilters && !showVersions;
const controlsDisabled = !isSoftwareEnabled || isTrulyEmpty;
const handleShowVersionsToggle = () => {
const queryParams: Record<string, string | number | boolean | undefined> = {
@@ -227,35 +229,42 @@ const SoftwareTable = ({
}
/>
)}
</>
);
};
const renderCustomControls = () => {
return (
<>
<Slider
value={showVersions}
onChange={handleShowVersionsToggle}
inactiveText="Show versions"
activeText="Show versions"
disabled={controlsDisabled}
/>
<TooltipWrapper
className={`${baseClass}__filters`}
position="left"
underline={false}
showArrow
tipOffset={12}
tipContent={vulnFilterDetails.tooltipText}
disableTooltip={!hasVulnFilters}
>
<Button
variant="inverse"
onClick={onAddFiltersClick}
disabled={controlsDisabled}
>
<Icon name="filter" />
<span>{vulnFilterDetails.buttonText}</span>
</Button>
</TooltipWrapper>
</>
);
};
const renderCustomFiltersButton = () => {
return (
<TooltipWrapper
className={`${baseClass}__filters`}
position="left"
underline={false}
showArrow
tipOffset={12}
tipContent={vulnFilterDetails.tooltipText}
disableTooltip={!hasVulnFilters}
>
<Button variant="inverse" onClick={onAddFiltersClick}>
<Icon name="filter" />
<span>{vulnFilterDetails.buttonText}</span>
</Button>
</TooltipWrapper>
);
};
const renderTableHelpText = () => (
<div>
Seeing unexpected software or vulnerabilities?{" "}
@@ -291,12 +300,11 @@ const SoftwareTable = ({
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!data?.meta.has_next_results}
searchable={showFilterHeaders}
searchable
disableSearch={controlsDisabled}
inputPlaceHolder="Search by name or vulnerability (CVE)"
onQueryChange={onQueryChange}
customControl={
showFilterHeaders ? renderCustomFiltersButton : undefined
}
customControl={renderCustomControls}
stackControls
renderCount={renderSoftwareCount}
renderTableHelpText={renderTableHelpText}
@@ -11,10 +11,10 @@
}
.table-container {
&__results-count {
.form-field--slider {
align-self: center;
}
.form-field--slider {
width: auto;
align-self: center;
white-space: nowrap;
}
&__search-input,
@@ -80,8 +80,8 @@ describe("Software library table", () => {
screen.getByRole("button", { name: "Add software" })
).toBeInTheDocument();
expect(screen.getByText("0 items")).toBeInTheDocument();
expect(screen.queryByText("Search")).toBeNull();
expect(screen.queryByText("Self-service only")).toBeNull();
expect(screen.getByPlaceholderText("Search by name")).toBeDisabled();
expect(screen.getByText("Self-service only")).toBeInTheDocument();
});
it("Renders the empty search state and self-service toggle when self-service filter is applied", () => {
@@ -137,12 +137,11 @@ const SoftwareLibraryTable = ({
return generateLibraryTableConfig(router, teamId);
}, [data, router, teamId]);
// Determines if a user should be able to filter or search in the table
const hasData = tableData && tableData.length > 0;
const hasQuery = query !== "";
const showFilterHeaders =
isSoftwareEnabled && (hasData || hasQuery || selfServiceOnly);
const isTrulyEmpty = !hasData && !hasQuery && !selfServiceOnly;
const controlsDisabled = !isSoftwareEnabled || isTrulyEmpty;
const handleSelfServiceToggle = () => {
const queryParams: Record<string, string | number | undefined> = {
@@ -202,6 +201,7 @@ const SoftwareLibraryTable = ({
onChange={handleSelfServiceToggle}
inactiveText="Self-service only"
activeText="Self-service only"
disabled={controlsDisabled}
/>
);
};
@@ -272,11 +272,12 @@ const SoftwareLibraryTable = ({
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!data?.meta.has_next_results}
searchable={showFilterHeaders}
searchable
disableSearch={controlsDisabled}
inputPlaceHolder="Search by name"
onQueryChange={onQueryChange}
additionalQueries={String(selfServiceOnly)}
customControl={showFilterHeaders ? renderCustomControls : undefined}
customControl={renderCustomControls}
stackControls
renderCount={renderSoftwareCount}
renderTableHelpText={renderTableHelpText}
@@ -94,7 +94,7 @@ const SoftwareOSTable = ({
currentPage,
teamId,
isLoading,
platform,
platform = "all",
}: ISoftwareOSTableProps) => {
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
@@ -168,11 +168,11 @@ const SoftwareOSTable = ({
router.push(path);
};
// Determines if a user should be able to filter the table
const hasData = data?.os_versions && data?.os_versions.length > 0;
const hasPlatformFilter = platform !== "all";
const showFilterHeaders = isSoftwareEnabled && (hasData || hasPlatformFilter);
const isTrulyEmpty = !hasData && !hasPlatformFilter;
const controlsDisabled = !isSoftwareEnabled || isTrulyEmpty;
const renderSoftwareCount = () => {
if (!data) return null;
@@ -180,7 +180,7 @@ const SoftwareOSTable = ({
return (
<>
<TableCount name="items" count={data?.count} />
{showFilterHeaders && data?.counts_updated_at && (
{!controlsDisabled && data?.counts_updated_at && (
<LastUpdatedText
lastUpdatedAt={data.counts_updated_at}
customTooltipText={
@@ -235,6 +235,7 @@ const SoftwareOSTable = ({
options={PLATFORM_FILTER_OPTIONS}
onChange={handlePlatformFilterDropdownChange}
variant="table-filter"
isDisabled={controlsDisabled}
/>
);
};
@@ -260,7 +261,7 @@ const SoftwareOSTable = ({
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
customControl={showFilterHeaders ? renderPlatformDropdown : undefined}
customControl={renderPlatformDropdown}
disableNextPage={!data?.meta.has_next_results}
searchable={false}
onQueryChange={onQueryChange}
@@ -83,7 +83,9 @@ describe("Software Vulnerabilities table", () => {
expect(screen.getByText("No vulnerabilities detected")).toBeInTheDocument();
expect(screen.getByText("0 items")).toBeInTheDocument();
expect(
screen.getByText("Expecting to see vulnerabilities? Check back later.")
screen.getByText(
"Vulnerability data will appear after the next scheduled check-in."
)
).toBeInTheDocument();
expect(screen.queryByText("Vulnerability")).toBeNull();
});
@@ -62,7 +62,7 @@ const SoftwareVulnerabilitiesTable = ({
isSoftwareEnabled,
data,
emptyStateReason,
query,
query = "",
perPage,
orderDirection,
orderKey,
@@ -142,12 +142,12 @@ const SoftwareVulnerabilitiesTable = ({
[determineQueryParamChange, generateNewQueryParams, router]
);
// determines if a user be able to search in the table
const searchable =
isSoftwareEnabled &&
(!!data?.vulnerabilities ||
query !== "" ||
showExploitedVulnerabilitiesOnly);
const hasData = (data?.vulnerabilities?.length ?? 0) > 0;
const hasQuery = query !== "";
const isTrulyEmpty =
!hasData && !hasQuery && !showExploitedVulnerabilitiesOnly;
const controlsDisabled = !isSoftwareEnabled || isTrulyEmpty;
const vulnerabilitiesTableHeaders = useMemo(() => {
if (!data) return [];
@@ -246,6 +246,7 @@ const SoftwareVulnerabilitiesTable = ({
newValue && handleExploitedVulnFilterDropdownChange(newValue.value)
}
variant="table-filter"
isDisabled={controlsDisabled}
/>
);
};
@@ -275,14 +276,13 @@ const SoftwareVulnerabilitiesTable = ({
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!data?.meta.has_next_results}
searchable={searchable}
searchable
disableSearch={controlsDisabled}
searchQueryColumn="vulnerability"
inputPlaceHolder="Search by CVE"
searchToolTipText={VULNERABILITIES_SEARCH_BOX_TOOLTIP}
onQueryChange={onQueryChange}
customControl={
searchable ? renderExploitedVulnerabilitiesDropdown : undefined
}
customControl={renderExploitedVulnerabilitiesDropdown}
stackControls
renderCount={renderVulnerabilityCount}
renderTableHelpText={renderTableHelpText}
@@ -17,10 +17,21 @@ export interface IEmptySoftwareTableProps {
platform?: HostPlatform;
}
const generateTypeText = (
/** Maps tableName to the truly-empty (no filters) info text. */
const EMPTY_INFO_BY_TABLE: Record<string, string> = {
software:
"Recently installed software will appear after the next scheduled check-in.",
"operating systems":
"Operating system data will appear after the next scheduled check-in.",
vulnerabilities:
"Vulnerability data will appear after the next scheduled check-in.",
};
/** Returns the display name used in filtered info text (e.g. "vulnerable software"). */
const getFilteredTypeText = (
tableName: string,
vulnFilters?: ISoftwareVulnFiltersParams
) => {
): string => {
if (vulnFilters?.vulnerable) {
return "vulnerable software";
}
@@ -35,15 +46,13 @@ const EmptySoftwareTable = ({
installableSoftwareExists,
platform,
}: IEmptySoftwareTableProps): JSX.Element => {
const softwareTypeText = generateTypeText(tableName, vulnFilters);
const { filterCount: vulnFiltersCount } = getVulnFilterRenderDetails(
vulnFilters
);
const isFiltered = vulnFiltersCount > 0 || !noSearchQuery;
const getEmptySoftwareInfo = (): IEmptyStateProps => {
const getEmptyStateProps = (): IEmptyStateProps => {
if (isSoftwareDisabled) {
return {
header: "Software inventory disabled",
@@ -61,11 +70,6 @@ const EmptySoftwareTable = ({
};
}
let info = `Expecting to see ${softwareTypeText}? Check back later.`;
if (isAndroid(platform || "")) {
info = `${info} It may take up to 24 hours for Android to report the software.`;
}
if (!isFiltered) {
if (installableSoftwareExists) {
return {
@@ -73,21 +77,34 @@ const EmptySoftwareTable = ({
info: "Install software on your hosts to see versions.",
};
}
let info = EMPTY_INFO_BY_TABLE[tableName] ?? EMPTY_INFO_BY_TABLE.software;
if (isAndroid(platform || "")) {
info = `${info} It may take up to 24 hours for Android to report the software.`;
}
return {
header: `No ${tableName} detected`,
info,
};
}
// Filtered/search state: use type-aware text (e.g. "vulnerable software")
const typeText = getFilteredTypeText(tableName, vulnFilters);
let info = `Expecting to see ${typeText}? Check back later.`;
if (isAndroid(platform || "")) {
info = `${info} It may take up to 24 hours for Android to report the software.`;
}
return {
header: "No items match the current search criteria",
info,
};
};
const emptySoftware = getEmptySoftwareInfo();
const emptyState = getEmptyStateProps();
return <EmptyState header={emptySoftware.header} info={emptySoftware.info} />;
return <EmptyState header={emptyState.header} info={emptyState.info} />;
};
export default EmptySoftwareTable;
@@ -26,7 +26,7 @@ const emptyStateDetails: Record<
> = {
"no-vulns-detected": {
header: "No vulnerabilities detected",
info: "Expecting to see vulnerabilities? Check back later.",
info: "Vulnerability data will appear after the next scheduled check-in.",
},
"no-matching-items": {
header: "No items match the current search criteria",