From 4fbdd341b3864558cc9d93c4326797dad89cbb10 Mon Sep 17 00:00:00 2001
From: Marko Lisica <83164494+marko-lisica@users.noreply.github.com>
Date: Tue, 7 Jul 2026 17:05:38 +0200
Subject: [PATCH] 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.*
## 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.
---
.../48614-hide-empty-self-service-categories | 1 +
.../SelfServiceCard/SelfServiceCard.tests.tsx | 45 ++++++++++-
.../SelfServiceCard/SelfServiceCard.tsx | 40 +++++++---
.../Software/SelfService/helpers.tests.ts | 74 +++++++++++++++++++
.../cards/Software/SelfService/helpers.ts | 17 +++++
5 files changed, 165 insertions(+), 12 deletions(-)
create mode 100644 changes/48614-hide-empty-self-service-categories
diff --git a/changes/48614-hide-empty-self-service-categories b/changes/48614-hide-empty-self-service-categories
new file mode 100644
index 0000000000..7a4df043f7
--- /dev/null
+++ b/changes/48614-hide-empty-self-service-categories
@@ -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.
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx
index dc6e1a3148..892d86a8e5 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx
@@ -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();
+
+ 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: [
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx
index 4ca5a8de20..422cf0b991 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx
@@ -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 = ({
@@ -292,7 +310,7 @@ const SelfServiceCard = ({
{
);
});
});
+
+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,
+ ]);
+ });
+});
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts
index e18e9baf5a..8f955e734c 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts
@@ -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();
+ 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[]