Fleet UI: Update empty states in software pages (#44979)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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" })
|
||||
|
||||
+1
-1
@@ -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={
|
||||
|
||||
+18
-8
@@ -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", () => {
|
||||
|
||||
+34
-26
@@ -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,
|
||||
|
||||
+2
-2
@@ -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", () => {
|
||||
|
||||
+6
-5
@@ -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}
|
||||
|
||||
+3
-1
@@ -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();
|
||||
});
|
||||
|
||||
+11
-11
@@ -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}
|
||||
|
||||
+29
-12
@@ -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;
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user