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[]