diff --git a/changes/48614-hide-empty-self-service-categories b/changes/48614-hide-empty-self-service-categories
new file mode 100644
index 00000000000..2b46768d70c
--- /dev/null
+++ b/changes/48614-hide-empty-self-service-categories
@@ -0,0 +1 @@
+- Hid 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 dc6e1a31483..efa494287d4 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,21 @@ describe("SelfServiceCard", () => {
// satisfy toHaveBeenCalled().
const pushSpy = jest.fn();
const mockRouter = createMockRouter({ push: pushSpy });
- const props = createTestProps({ router: mockRouter });
+ // The dropdown only lists categories that have self-service software (#48614),
+ // so the software must actually belong to "🌎 Browsers" for it to appear.
+ 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 +270,37 @@ describe("SelfServiceCard", () => {
);
});
+ it("hides categories that have no self-service software (#48614)", async () => {
+ // BE returns both categories, but only "🌎 Browsers" has software for this
+ // host — "🔐 Security" should never appear in the dropdown.
+ 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 4ca5a8de20b..0ad38838ceb 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,25 @@ const SelfServiceCard = ({
const categories = useMemo(() => categoriesData ?? [], [categoriesData]);
+ // Hide categories that have no self-service software for this host (#48614).
+ // enhancedSoftware holds the host's full self-service list (the API isn't
+ // paginated), so an empty category here means selecting it would show nothing.
+ // Everything downstream (filter dropdown, selected-category software,
+ // stale-link recovery) keys off this so empty categories behave as if they
+ // don't exist.
+ 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 +198,31 @@ 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 the software list too: visibleCategories is derived from it, so
+ // acting before it loads could clear a valid category_id during the window
+ // where categories have resolved but software hasn't.
+ 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 +280,7 @@ const SelfServiceCard = ({
@@ -292,7 +316,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 e18e9baf5a2..adb65722034 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts
@@ -100,6 +100,29 @@ export const filterSoftwareByCustomCategory = (
});
};
+// Returns only the categories that have at least one self-service software item
+// assigned for this host, so empty categories are hidden from the filter
+// (#48614). Category membership is resolved the same way as
+// `filterSoftwareByCustomCategory` (matching on lowercased name across
+// `software_package` and `app_store_app` categories) so the dropdown stays
+// consistent with what selecting a category would actually show. The host's
+// full self-service list is available client-side (the API isn't paginated), so
+// this reflects MDM enrollment, label scoping, and platform exactly as resolved
+// by the backend software query.
+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[]