diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 33bf8f590b..c5ce2dbf44 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -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 = { 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", diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index 7002cae6f6..06c316705c 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx @@ -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(); + + 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(); + + 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(); + + expect( + screen.getByText(/installed all the software in self-service/i) + ).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index 6e8d0f2a87..871e1ba25e 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -1547,6 +1547,24 @@ const TAGGED_TEMPLATES = { ); }, + installedAllSelfServiceSoftware: (activity: IActivity) => { + const categoryName = activity.details?.self_service_category_name; + if (categoryName) { + return ( + <> + {" "} + End user selected the Install all option in the + self-service {categoryName} category. + + ); + } + return ( + <> + {" "} + End user 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. " 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 diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx index f1b3725429..d3e868483f 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx @@ -389,6 +389,7 @@ const EditSoftwareModal = ({ onCancel={onExit} isLoading={isUpdatingSoftware} onClickPreviewEndUserExperience={togglePreviewEndUserExperienceModal} + teamId={teamId} /> ); }; diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tests.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tests.tsx index ca93ee7955..eb33bd4d9a 100644 --- a/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tests.tsx +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tests.tsx @@ -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); diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx index cf9dd89519..16fae84f4b 100644 --- a/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx @@ -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} diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx index 60f5465775..c40fe369b2 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx @@ -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, diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/InstalledAllSelfServiceSoftwareActivityItem.tests.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/InstalledAllSelfServiceSoftwareActivityItem.tests.tsx new file mode 100644 index 0000000000..aa015d8c79 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/InstalledAllSelfServiceSoftwareActivityItem.tests.tsx @@ -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(); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/InstalledAllSelfServiceSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/InstalledAllSelfServiceSoftwareActivityItem.tsx new file mode 100644 index 0000000000..daa6f3630c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/InstalledAllSelfServiceSoftwareActivityItem.tsx @@ -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; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/index.ts new file mode 100644 index 0000000000..37000621fd --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledAllSelfServiceSoftwareActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./InstalledAllSelfServiceSoftwareActivityItem"; 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 3013732be8..0b558c2458 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 @@ -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 }, diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.tests.ts b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.tests.ts index d3d41274cf..9b7c727e57 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.tests.ts +++ b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.tests.ts @@ -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 = [ diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts index 8a23bbbf6b..0af30ff55f 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts +++ b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts @@ -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 ?? []),