Hide empty self-service categories on My device page (#48614) (#48619)

**Related issue:** Resolves #48614

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

*Note: this is a frontend-only change; no backend endpoints, database
schema, or configuration settings were modified.*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* On the **My device** page, the self-service category filter now hides
categories that have no installable software available for the host.
* Valid category selections are preserved during mid-load, and any
category from a shared link is only applied if it exists in the
currently available set.
* Category matching remains case-insensitive and supports both
package-based and app store software.

* **Tests**
* Expanded coverage for category filtering and dropdown rendering
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Marko Lisica
2026-07-07 17:05:38 +02:00
committed by GitHub
parent a979c86bbe
commit 4fbdd341b3
5 changed files with 165 additions and 12 deletions
@@ -0,0 +1 @@
- Hide self-service categories that have no available software from the category filter on the **My device** page, so users only see categories they can actually install from.
@@ -239,7 +239,20 @@ describe("SelfServiceCard", () => {
// satisfy toHaveBeenCalled().
const pushSpy = jest.fn();
const mockRouter = createMockRouter({ push: pushSpy });
const props = createTestProps({ router: mockRouter });
// Only categories with software appear, so the software must be in Browsers.
const browserPackage = createMockHostSoftwarePackage({
categories: (["🌎 Browsers"] as string[]) as SoftwareCategory[],
});
const props = createTestProps({
router: mockRouter,
enhancedSoftware: [
{
...createMockDeviceSoftware({ name: "browser" }),
ui_status: "uninstalled",
software_package: browserPackage,
},
],
});
const render = createCustomRenderer({ withBackendMock: true });
const user = userEvent.setup();
@@ -256,6 +269,36 @@ describe("SelfServiceCard", () => {
);
});
it("hides categories that have no self-service software", async () => {
// BE returns both, but only Browsers has software, so Security is hidden.
mockServer.use(
listDeviceSelfServiceCategoriesHandler([
{ id: 1, name: "🌎 Browsers" },
{ id: 2, name: "🔐 Security" },
])
);
const browserPackage = createMockHostSoftwarePackage({
categories: (["🌎 Browsers"] as string[]) as SoftwareCategory[],
});
const props = createTestProps({
enhancedSoftware: [
{
...createMockDeviceSoftware({ name: "browser" }),
ui_status: "uninstalled",
software_package: browserPackage,
},
],
});
const render = createCustomRenderer({ withBackendMock: true });
const user = userEvent.setup();
render(<SelfServiceCard {...props} />);
await user.click(await screen.findByRole("button", { expanded: false }));
expect(await screen.findByText("🌎 Browsers")).toBeInTheDocument();
expect(screen.queryByText("🔐 Security")).not.toBeInTheDocument();
});
it("renders the install-all button enabled when 'All' is selected and items are eligible", () => {
const props = createTestProps({
enhancedSoftware: [
@@ -22,6 +22,7 @@ import SelfServiceTable from "../components/SelfServiceTable";
import SelfServiceTiles from "../components/SelfServiceTiles";
import {
countUninstalledForInstallAll,
filterCategoriesWithSoftware,
filterSoftwareByCustomCategory,
hasInProgressInstallAllItems,
} from "../helpers";
@@ -94,14 +95,21 @@ const SelfServiceCard = ({
const categories = useMemo(() => categoriesData ?? [], [categoriesData]);
// Hide categories with no software. enhancedSoftware is the host's full
// self-service list (unpaginated), so everything downstream keys off this.
const visibleCategories = useMemo(
() => filterCategoriesWithSoftware(categories, enhancedSoftware),
[categories, enhancedSoftware]
);
const softwareInSelectedCategory = useMemo(
() =>
filterSoftwareByCustomCategory(
enhancedSoftware,
categories,
visibleCategories,
queryParams.category_id
),
[enhancedSoftware, categories, queryParams.category_id]
[enhancedSoftware, visibleCategories, queryParams.category_id]
);
const uninstalledCount = useMemo(
@@ -186,19 +194,29 @@ const SelfServiceCard = ({
);
// Recover from stale links: if the URL has a category_id that doesn't match
// any loaded category (admin deleted it, or the list resolved empty), the
// trigger label would fall through to "All" while filterSoftwareByCustomCategory
// returns [] — contradicting what the label promises. Drop the param so the
// user lands back on a real "All" view.
// any visible category (admin deleted it, the list resolved empty, or the
// category no longer has any self-service software), the trigger label would
// fall through to "All" while filterSoftwareByCustomCategory returns [] —
// contradicting what the label promises. Drop the param so the user lands
// back on a real "All" view.
useEffect(() => {
if (!isCategoriesSuccess || queryParams.category_id === undefined) return;
const idIsKnown = categories.some((c) => c.id === queryParams.category_id);
// Wait for software too, else a valid category_id is cleared mid-load.
if (
!isCategoriesSuccess ||
!selfServiceData ||
queryParams.category_id === undefined
)
return;
const idIsKnown = visibleCategories.some(
(c) => c.id === queryParams.category_id
);
if (!idIsKnown) {
onCategoryChange(undefined);
}
}, [
isCategoriesSuccess,
categories,
selfServiceData,
visibleCategories,
queryParams.category_id,
onCategoryChange,
]);
@@ -256,7 +274,7 @@ const SelfServiceCard = ({
<SelfServiceFilters
query={queryParams.query}
categoryId={queryParams.category_id}
categories={categories}
categories={visibleCategories}
onSearchQueryChange={onSearchQueryChange}
onCategoryChange={onCategoryChange}
/>
@@ -292,7 +310,7 @@ const SelfServiceCard = ({
<SelfServiceFilters
query={queryParams.query}
categoryId={queryParams.category_id}
categories={categories}
categories={visibleCategories}
onSearchQueryChange={onSearchQueryChange}
onCategoryChange={onCategoryChange}
installAllSlot={installAllButton}
@@ -9,6 +9,7 @@ import { createMockSelfServiceCategory } from "test/handlers/self-service-catego
import {
countUninstalledForInstallAll,
hasInProgressInstallAllItems,
filterCategoriesWithSoftware,
filterSoftwareByCustomCategory,
} from "./helpers";
@@ -259,3 +260,76 @@ describe("filterSoftwareByCustomCategory", () => {
);
});
});
describe("filterCategoriesWithSoftware", () => {
const browsersPackage = createMockHostSoftwarePackage({
categories: (["🌎 Browsers"] as string[]) as SoftwareCategory[],
});
const securityPackage = createMockHostSoftwarePackage({
categories: (["🔐 Security"] as string[]) as SoftwareCategory[],
});
const browser = makeItem("uninstalled", {
name: "browser",
software_package: browsersPackage,
});
const security = makeItem("uninstalled", {
name: "security",
software_package: securityPackage,
});
const browsers = createMockSelfServiceCategory({
id: 1,
name: "🌎 Browsers",
});
const securityCat = createMockSelfServiceCategory({
id: 2,
name: "🔐 Security",
});
const devTools = createMockSelfServiceCategory({
id: 3,
name: "🧰 Developer tools",
});
it("keeps only categories that have at least one software item", () => {
expect(
filterCategoriesWithSoftware(
[browsers, securityCat, devTools],
[browser, security]
)
).toEqual([browsers, securityCat]);
});
it("drops every category when there is no software", () => {
expect(
filterCategoriesWithSoftware([browsers, securityCat, devTools], [])
).toEqual([]);
});
it("returns [] when there are no categories", () => {
expect(filterCategoriesWithSoftware([], [browser, security])).toEqual([]);
});
it("matches case-insensitively", () => {
const lowerBrowsers = createMockSelfServiceCategory({
id: 1,
name: "🌎 browsers",
});
expect(filterCategoriesWithSoftware([lowerBrowsers], [browser])).toEqual([
lowerBrowsers,
]);
});
it("considers categories on app_store_app as well as software_package", () => {
const vppApp = makeItem("uninstalled", {
name: "vpp-app",
software_package: null,
app_store_app: {
...createMockHostSoftwarePackage(),
categories: ["🌎 Browsers"],
} as never,
});
expect(filterCategoriesWithSoftware([browsers], [vppApp])).toEqual([
browsers,
]);
});
});
@@ -100,6 +100,23 @@ export const filterSoftwareByCustomCategory = (
});
};
// Keeps only categories that have at least one software item. Membership is
// resolved the same way as `filterSoftwareByCustomCategory` so the dropdown
// stays consistent with what selecting a category shows.
export const filterCategoriesWithSoftware = (
categories: ISelfServiceCategory[],
software: IDeviceSoftwareWithUiStatus[]
): ISelfServiceCategory[] => {
const categoryNamesInUse = new Set<string>();
software.forEach((item) => {
[
...(item.software_package?.categories ?? []),
...(item.app_store_app?.categories ?? []),
].forEach((name) => categoryNamesInUse.add(name.toLowerCase()));
});
return categories.filter((c) => categoryNamesInUse.has(c.name.toLowerCase()));
};
/** Count of items in the list that are eligible to be queued by install_all. */
export const countUninstalledForInstallAll = (
software: IDeviceSoftwareWithUiStatus[]