Fleet UI: Self-service categories follow-ups (#46860)

**Related issue:** Resolves #39018

Follow-ups to the self-service custom categories work, landing on
`feat/39018-self-service-categories`:

1. **Match categories by full name (with emoji).** Drops the client-side
emoji-prefix stripping in `helpers.ts`. Both sides of the comparison
(custom category and software's `categories`) carry the emoji prefix, so
lowercase exact match works.
2. **Render `installed_all_self_service_software` activity.** New host
activity item + global feed template, matching the BE contract
(`self_service_category_id|name`, `software_titles_count`). Falls back
to an un-scoped "End user installed all the software in self-service."
when the install-all wasn't category-scoped.
3. **VPP edit modal: dynamic custom categories.** `EditSoftwareModal`
now passes `teamId` through `SoftwareVppForm` →
`SoftwareOptionsSelector`, so the VPP edit modal fetches custom
categories from the API instead of falling back to the hardcoded list.
`teamId === 0` (no team) is covered.

  ## Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented...

  ## Testing

  - [x] Added/updated automated tests
  - [x] QA'd all new/changed functionality manually

---------

Co-authored-by: jkatz01 <yehonatankatz@gmail.com>
This commit is contained in:
Carlo
2026-06-05 15:40:20 -04:00
committed by GitHub
co-authored by jkatz01
parent d6b56d81bd
commit 09bccefb5b
13 changed files with 224 additions and 26 deletions
+6
View File
@@ -129,6 +129,7 @@ export enum ActivityType {
EditedSoftware = "edited_software",
DeletedSoftware = "deleted_software",
InstalledSoftware = "installed_software",
InstalledAllSelfServiceSoftware = "installed_all_self_service_software",
UninstalledSoftware = "uninstalled_software",
EnabledVpp = "enabled_vpp",
DisabledVpp = "disabled_vpp",
@@ -196,6 +197,7 @@ export type IHostPastActivityType =
| ActivityType.RotatedHostRecoveryLockPassword
| ActivityType.UnlockedHost
| ActivityType.InstalledSoftware
| ActivityType.InstalledAllSelfServiceSoftware
| ActivityType.UninstalledSoftware
| ActivityType.InstalledAppStoreApp
| ActivityType.CanceledRunScript
@@ -296,9 +298,12 @@ export interface IActivityDetails {
script_execution_id?: string;
script_name?: string;
self_service?: boolean;
self_service_category_id?: number | null;
self_service_category_name?: string | null;
software_package?: string;
software_title_id?: number;
software_title?: string;
software_titles_count?: number;
/** Custom name set per team by admin */
software_display_name?: string;
source?: SoftwareSource;
@@ -453,6 +458,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record<ActivityType, string> = {
fleet_enrolled: "Host enrolled",
installed_app_store_app: "Installed App Store (VPP) app",
installed_software: "Install software",
installed_all_self_service_software: "Installed all self-service software",
live_query: "Ran live report",
locked_host: "Locked host",
mdm_enrolled: "MDM turned on",
@@ -2140,4 +2140,45 @@ describe("Activity Feed", () => {
expect(screen.getByText("deleted the label .")).toBeInTheDocument();
expect(screen.getByText("Workstations")).toBeInTheDocument();
});
it("renders an un-scoped installed_all_self_service_software activity", () => {
const activity = createMockActivity({
type: ActivityType.InstalledAllSelfServiceSoftware,
actor_full_name: "Test User",
details: {},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText("End user")).toBeInTheDocument();
expect(
screen.getByText(/installed all the software in self-service/i)
).toBeInTheDocument();
// The actor is dropped in favor of "End user".
expect(screen.queryByText("Test User")).not.toBeInTheDocument();
});
it("renders a category-scoped installed_all_self_service_software activity", () => {
const activity = createMockActivity({
type: ActivityType.InstalledAllSelfServiceSoftware,
details: { self_service_category_name: "Productivity" },
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText("End user")).toBeInTheDocument();
expect(screen.getByText("Install all")).toBeInTheDocument();
expect(screen.getByText("Productivity")).toBeInTheDocument();
expect(screen.getByText(/in the self-service/i)).toBeInTheDocument();
});
it("treats a null category the same as un-scoped (installed_all_self_service_software)", () => {
const activity = createMockActivity({
type: ActivityType.InstalledAllSelfServiceSoftware,
details: { self_service_category_name: null },
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText(/installed all the software in self-service/i)
).toBeInTheDocument();
});
});
@@ -1547,6 +1547,24 @@ const TAGGED_TEMPLATES = {
</>
);
},
installedAllSelfServiceSoftware: (activity: IActivity) => {
const categoryName = activity.details?.self_service_category_name;
if (categoryName) {
return (
<>
{" "}
<b>End user</b> selected the <b>Install all</b> option in the
self-service <b>{categoryName}</b> category.
</>
);
}
return (
<>
{" "}
<b>End user</b> installed all the software in self-service.
</>
);
},
enabledVpp: (activity: IActivity) => {
return (
<>
@@ -2437,6 +2455,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
case ActivityType.InstalledSoftware: {
return TAGGED_TEMPLATES.installedSoftware(activity);
}
case ActivityType.InstalledAllSelfServiceSoftware: {
return TAGGED_TEMPLATES.installedAllSelfServiceSoftware(activity);
}
case ActivityType.UninstalledSoftware: {
return TAGGED_TEMPLATES.uninstalledSoftware(activity);
}
@@ -2617,6 +2638,9 @@ const GlobalActivityItem = ({
// template (e.g. "<title> was installed on <host> (self-service).")
// without an actor prefix.
return activity.details?.self_service ? null : DEFAULT_ACTOR_DISPLAY;
case ActivityType.InstalledAllSelfServiceSoftware:
// The template carries the "End user" subject for this roll-up.
return null;
// these activities have more complicated logic to
// determine if we display the actor name so we will handle that in the
// template function
@@ -389,6 +389,7 @@ const EditSoftwareModal = ({
onCancel={onExit}
isLoading={isUpdatingSoftware}
onClickPreviewEndUserExperience={togglePreviewEndUserExperienceModal}
teamId={teamId}
/>
);
};
@@ -98,6 +98,21 @@ describe("SoftwareOptionsSelector", () => {
expect(screen.getByText("🔐 Security")).toBeInTheDocument();
});
it("treats teamId 0 (no team) as dynamic, fetching categories from the API", async () => {
// A name absent from the hardcoded fallback proves teamId 0 queried the API.
mockServer.use(
listSelfServiceCategoriesHandler([
{ id: 9, name: "🛟 No-team custom category" },
])
);
renderComponent({ ...selfServiceEditingProps, teamId: 0 });
expect(
await screen.findByText("🛟 No-team custom category")
).toBeInTheDocument();
});
it("shows the empty state with an Add category link when no categories exist", async () => {
mockServer.use(emptySelfServiceCategoriesHandler);
@@ -118,6 +118,8 @@ interface ISoftwareVppFormProps {
isLoading?: boolean;
onCancel: () => void;
onClickPreviewEndUserExperience: (isIosOrIpadosApp: boolean) => void;
/** When provided, the categories list is fetched dynamically for this fleet. */
teamId?: number;
}
const SoftwareVppForm = ({
@@ -128,6 +130,7 @@ const SoftwareVppForm = ({
isLoading = false,
onCancel,
onClickPreviewEndUserExperience,
teamId,
}: ISoftwareVppFormProps) => {
const { gitOpsModeEnabled } = useGitOpsMode("software");
@@ -270,6 +273,7 @@ const SoftwareVppForm = ({
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(isAppleMobile)
}
teamId={teamId}
/>
<TargetLabelSelector
selectedTargetType={formData.targetType}
@@ -20,6 +20,7 @@ import ViewedHostRecoveryLockPasswordActivityItem from "./ActivityItems/ViewedHo
import SetHostRecoveryLockPasswordActivityItem from "./ActivityItems/SetHostRecoveryLockPassword";
import RotatedHostRecoveryLockPasswordActivityItem from "./ActivityItems/RotatedHostRecoveryLockPassword";
import InstalledSoftwareActivityItem from "./ActivityItems/InstalledSoftwareActivityItem";
import InstalledAllSelfServiceSoftwareActivityItem from "./ActivityItems/InstalledAllSelfServiceSoftwareActivityItem";
import CanceledRunScriptActivityItem from "./ActivityItems/CanceledRunScriptActivityItem";
import CanceledInstallSoftwareActivityItem from "./ActivityItems/CanceledInstallSoftwareActivityItem";
import CanceledSetupExperienceActivityItem from "./ActivityItems/CanceledSetupExperienceActivityItem";
@@ -71,6 +72,7 @@ export const pastActivityComponentMap: Record<
[ActivityType.RotatedHostRecoveryLockPassword]: RotatedHostRecoveryLockPasswordActivityItem,
[ActivityType.UnlockedHost]: UnlockedHostActivityItem,
[ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
[ActivityType.InstalledAllSelfServiceSoftware]: InstalledAllSelfServiceSoftwareActivityItem,
[ActivityType.UninstalledSoftware]: InstalledSoftwareActivityItem,
[ActivityType.InstalledAppStoreApp]: InstalledSoftwareActivityItem,
[ActivityType.CanceledRunScript]: CanceledRunScriptActivityItem,
@@ -0,0 +1,76 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { createMockHostPastActivity } from "__mocks__/activityMock";
import { ActivityType } from "interfaces/activity";
import InstalledAllSelfServiceSoftwareActivityItem from "./InstalledAllSelfServiceSoftwareActivityItem";
describe("InstalledAllSelfServiceSoftwareActivityItem", () => {
it("renders the un-scoped roll-up as an end-user action", () => {
render(
<InstalledAllSelfServiceSoftwareActivityItem
activity={createMockHostPastActivity({
type: ActivityType.InstalledAllSelfServiceSoftware,
actor_full_name: "Test User",
details: {},
})}
tab="past"
/>
);
expect(screen.getByText("End user")).toBeVisible();
expect(
screen.getByText(/installed all the software in self-service/i)
).toBeVisible();
// The actor is dropped in favor of "End user".
expect(screen.queryByText("Test User")).not.toBeInTheDocument();
});
it("treats a null category name the same as un-scoped", () => {
render(
<InstalledAllSelfServiceSoftwareActivityItem
activity={createMockHostPastActivity({
type: ActivityType.InstalledAllSelfServiceSoftware,
details: { self_service_category_name: null },
})}
tab="past"
/>
);
expect(
screen.getByText(/installed all the software in self-service/i)
).toBeVisible();
});
it("names the category when the roll-up is category-scoped", () => {
render(
<InstalledAllSelfServiceSoftwareActivityItem
activity={createMockHostPastActivity({
type: ActivityType.InstalledAllSelfServiceSoftware,
details: { self_service_category_name: "Productivity" },
})}
tab="past"
/>
);
expect(screen.getByText("End user")).toBeVisible();
expect(screen.getByText("Install all")).toBeVisible();
expect(screen.getByText("Productivity")).toBeVisible();
expect(screen.getByText(/in the self-service/i)).toBeVisible();
});
it("does not render the cancel or show details icons", () => {
render(
<InstalledAllSelfServiceSoftwareActivityItem
activity={createMockHostPastActivity({
type: ActivityType.InstalledAllSelfServiceSoftware,
details: {},
})}
tab="past"
/>
);
expect(screen.queryByTestId("close-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("info-outline-icon")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,37 @@
import React from "react";
import ActivityItem from "components/ActivityItem";
import { IHostActivityItemComponentProps } from "../../ActivityConfig";
const baseClass = "installed-all-self-service-software-activity-item";
const InstalledAllSelfServiceSoftwareActivityItem = ({
activity,
}: IHostActivityItemComponentProps) => {
const categoryName = activity.details.self_service_category_name;
// Self-service install-all can be triggered by anyone who opens the host's My
// device page, so the actor is dropped in favor of "End user".
return (
<ActivityItem
className={baseClass}
activity={activity}
hideCancel
hideShowDetails
>
{categoryName ? (
<>
<b>End user</b> selected the <b>Install all</b> option in the
self-service <b>{categoryName}</b> category.
</>
) : (
<>
<b>End user</b> installed all the software in self-service.
</>
)}
</ActivityItem>
);
};
export default InstalledAllSelfServiceSoftwareActivityItem;
@@ -0,0 +1 @@
export { default } from "./InstalledAllSelfServiceSoftwareActivityItem";
@@ -12,6 +12,7 @@ import { baseUrl } from "test/default-handlers";
import { listDeviceSelfServiceCategoriesHandler } from "test/handlers/self-service-categories-handlers";
import { createMockDeviceSoftware } from "__mocks__/deviceUserMock";
import { createMockHostSoftwarePackage } from "__mocks__/hostMock";
import { SoftwareCategory } from "interfaces/software";
import SelfServiceCard, {
SelfServiceQueryParams,
@@ -278,7 +279,7 @@ describe("SelfServiceCard", () => {
listDeviceSelfServiceCategoriesHandler([{ id: 1, name: "🌎 Browsers" }])
);
const browserPackage = createMockHostSoftwarePackage({
categories: ["Browsers"],
categories: (["🌎 Browsers"] as string[]) as SoftwareCategory[],
});
const props = createTestProps({
queryParams: { ...DEFAULT_QUERY_PARAMS, category_id: 1 },
@@ -1,4 +1,7 @@
import { IDeviceSoftwareWithUiStatus } from "interfaces/software";
import {
IDeviceSoftwareWithUiStatus,
SoftwareCategory,
} from "interfaces/software";
import { createMockDeviceSoftware } from "__mocks__/deviceUserMock";
import { createMockHostSoftwarePackage } from "__mocks__/hostMock";
import { createMockSelfServiceCategory } from "test/handlers/self-service-categories-handlers";
@@ -160,10 +163,10 @@ describe("hasInProgressInstallAllItems", () => {
describe("filterSoftwareByCustomCategory", () => {
const browsersPackage = createMockHostSoftwarePackage({
categories: ["Browsers"],
categories: (["🌎 Browsers"] as string[]) as SoftwareCategory[],
});
const securityPackage = createMockHostSoftwarePackage({
categories: ["Security"],
categories: (["🔐 Security"] as string[]) as SoftwareCategory[],
});
const browser = makeItem("uninstalled", {
@@ -196,7 +199,7 @@ describe("filterSoftwareByCustomCategory", () => {
).toEqual([]);
});
it("filters items matching the category (after stripping emoji prefix)", () => {
it("filters items matching the selected category by name", () => {
const categories = [
createMockSelfServiceCategory({ id: 1, name: "🌎 Browsers" }),
];
@@ -205,9 +208,9 @@ describe("filterSoftwareByCustomCategory", () => {
).toEqual([browser]);
});
it("matches case-insensitively (custom category 'utilities' matches 'Utilities')", () => {
it("matches case-insensitively", () => {
const utilitiesPackage = createMockHostSoftwarePackage({
categories: ["Utilities"],
categories: (["🛠️ Utilities"] as string[]) as SoftwareCategory[],
});
const item = makeItem("uninstalled", {
name: "ohai",
@@ -227,7 +230,7 @@ describe("filterSoftwareByCustomCategory", () => {
software_package: null,
app_store_app: {
...createMockHostSoftwarePackage(),
categories: ["Browsers"],
categories: ["🌎 Browsers"],
} as never,
});
const categories = [
@@ -59,23 +59,10 @@ export const CATEGORIES_ITEMS: ICategory[] = [
{ id: 6, label: "🛠️ Utilities", value: "Utilities" },
];
/**
* Strips a leading emoji + whitespace from a custom category name so it can be
* compared against software's existing `categories: SoftwareCategory[]` enum.
*
* BE will eventually associate software to custom categories by ID and this
* helper will become obsolete the device software endpoint will accept
* `category_id` and filter server-side. Until then this gives a best-effort
* client-side fallback for dev mode (#46369).
*/
const stripEmojiPrefix = (name: string): string =>
name.replace(/^[^\p{L}\p{N}]+/u, "").trim();
/**
* Returns software in the given custom category. Best-effort name match see
* `stripEmojiPrefix` doc comment. Returns the unmodified list when category is
* undefined (the "All" filter).
*/
// Client-side category filter by name — both sides come from
// `software_categories` until BE supports server-side `category_id` (#46369).
// `categoryId === undefined` is the "All" filter (returns input unchanged);
// an unknown id (stale URL or still-loading list) returns `[]`.
export const filterSoftwareByCustomCategory = (
software: IDeviceSoftwareWithUiStatus[],
categories: ISelfServiceCategory[],
@@ -91,7 +78,7 @@ export const filterSoftwareByCustomCategory = (
if (!category) {
return [];
}
const normalized = stripEmojiPrefix(category.name).toLowerCase();
const normalized = category.name.toLowerCase();
return software.filter((item) => {
const itemCategories = [
...(item.software_package?.categories ?? []),