From e1af987f93191fa2f3b5d7f2b4b2ab1340dd35db Mon Sep 17 00:00:00 2001 From: Marko Lisica Date: Thu, 2 Jul 2026 15:37:34 +0200 Subject: [PATCH] Hide empty self-service categories on My device page (#48614) --- .../48614-hide-empty-self-service-categories | 1 + .../SelfServiceCard/SelfServiceCard.tests.tsx | 47 +++++++++++- .../SelfServiceCard/SelfServiceCard.tsx | 46 +++++++++--- .../Software/SelfService/helpers.tests.ts | 74 +++++++++++++++++++ .../cards/Software/SelfService/helpers.ts | 23 ++++++ 5 files changed, 179 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 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[]