Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/48614-hide-empty-self-service-categories
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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(<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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import SelfServiceTable from "../components/SelfServiceTable";
import SelfServiceTiles from "../components/SelfServiceTiles";
import {
countUninstalledForInstallAll,
filterCategoriesWithSoftware,
filterSoftwareByCustomCategory,
hasInProgressInstallAllItems,
} from "../helpers";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
]);
Expand Down Expand Up @@ -256,7 +280,7 @@ const SelfServiceCard = ({
<SelfServiceFilters
query={queryParams.query}
categoryId={queryParams.category_id}
categories={categories}
categories={visibleCategories}
onSearchQueryChange={onSearchQueryChange}
onCategoryChange={onCategoryChange}
/>
Expand Down Expand Up @@ -292,7 +316,7 @@ const SelfServiceCard = ({
<SelfServiceFilters
query={queryParams.query}
categoryId={queryParams.category_id}
categories={categories}
categories={visibleCategories}
onSearchQueryChange={onSearchQueryChange}
onCategoryChange={onCategoryChange}
installAllSlot={installAllButton}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createMockSelfServiceCategory } from "test/handlers/self-service-catego
import {
countUninstalledForInstallAll,
hasInProgressInstallAllItems,
filterCategoriesWithSoftware,
filterSoftwareByCustomCategory,
} from "./helpers";

Expand Down Expand Up @@ -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,
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<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[]
Expand Down
Loading