**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.* <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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.
|
||||
+44
-1
@@ -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(<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: [
|
||||
|
||||
+29
-11
@@ -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 = ({
|
||||
<SelfServiceFilters
|
||||
query={queryParams.query}
|
||||
categoryId={queryParams.category_id}
|
||||
categories={categories}
|
||||
categories={visibleCategories}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onCategoryChange={onCategoryChange}
|
||||
/>
|
||||
@@ -292,7 +310,7 @@ const SelfServiceCard = ({
|
||||
<SelfServiceFilters
|
||||
query={queryParams.query}
|
||||
categoryId={queryParams.category_id}
|
||||
categories={categories}
|
||||
categories={visibleCategories}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onCategoryChange={onCategoryChange}
|
||||
installAllSlot={installAllButton}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createMockSelfServiceCategory } from "test/handlers/self-service-catego
|
||||
import {
|
||||
countUninstalledForInstallAll,
|
||||
hasInProgressInstallAllItems,
|
||||
filterCategoriesWithSoftware,
|
||||
filterSoftwareByCustomCategory,
|
||||
} from "./helpers";
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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[]
|
||||
|
||||
Reference in New Issue
Block a user