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:
@@ -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",
|
||||
|
||||
+41
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
+1
@@ -389,6 +389,7 @@ const EditSoftwareModal = ({
|
||||
onCancel={onExit}
|
||||
isLoading={isUpdatingSoftware}
|
||||
onClickPreviewEndUserExperience={togglePreviewEndUserExperienceModal}
|
||||
teamId={teamId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+76
@@ -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();
|
||||
});
|
||||
});
|
||||
+37
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./InstalledAllSelfServiceSoftwareActivityItem";
|
||||
+2
-1
@@ -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 ?? []),
|
||||
|
||||
Reference in New Issue
Block a user