From 175b2419a4079e7d8eb5c4492ec2fd2f549ad9d8 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:14:39 -0400 Subject: [PATCH] Fleet UI: Route software title names through getDisplayedSoftwareName (#47084) --- .claude/rules/fleet-frontend.md | 3 ++ changes/45645-software-display-name | 1 + .../components/SoftwarePicker.tests.tsx | 42 +++++++++++++++++++ .../components/SoftwarePicker.tsx | 7 +++- frontend/docs/patterns.md | 31 ++++++++++++++ frontend/pages/SoftwarePage/helpers.tests.tsx | 8 ++++ frontend/pages/SoftwarePage/helpers.tsx | 7 ++-- .../HostsFilterBlock/HostsFilterBlock.tsx | 4 +- .../SetupStatusTableConfig.tsx | 3 +- .../InstallStatusCell/InstallStatusCell.tsx | 2 +- .../ManagePoliciesPage/helpers.tests.tsx | 11 +++++ .../policies/ManagePoliciesPage/helpers.tsx | 7 +++- .../PatchAutomationCta.tests.tsx | 39 +++++++++++++++++ .../PatchAutomationCta/PatchAutomationCta.tsx | 12 +++--- .../PolicyAutomationsFields.tsx | 3 +- .../PolicyAutomationsList.tests.tsx | 37 ++++++++++++++++ .../PolicyAutomationsList.tsx | 9 +++- 17 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 changes/45645-software-display-name diff --git a/.claude/rules/fleet-frontend.md b/.claude/rules/fleet-frontend.md index 3476f248ce..4303b0b863 100644 --- a/.claude/rules/fleet-frontend.md +++ b/.claude/rules/fleet-frontend.md @@ -83,6 +83,9 @@ Use helpers from `frontend/utilities/strings/stringUtils.ts`: - `stripQuotes(str)`, `strToBool(str)` — input parsing - `enforceFleetSentenceCasing(str)` — respects Fleet stylization rules +## Software titles — display name +Render software title names via `getDisplayedSoftwareName(name, display_name)` from `pages/SoftwarePage/helpers.tsx` — never raw `t.name` or open-coded `display_name || name`. See `frontend/docs/patterns.md`. + ## Styling (SCSS + BEM) - Define `const baseClass = "component-name"` at the top of the component - Elements: `` className={`${baseClass}__element-name`} `` diff --git a/changes/45645-software-display-name b/changes/45645-software-display-name new file mode 100644 index 0000000000..d8d50094ba --- /dev/null +++ b/changes/45645-software-display-name @@ -0,0 +1 @@ +- Fixed software titles displaying the raw package name instead of the admin-set display name in the policy automations list and edit modal, the patch automation CTA, the hosts software filter pill, and the setup experience software row. diff --git a/frontend/components/CommandPalette/components/SoftwarePicker.tests.tsx b/frontend/components/CommandPalette/components/SoftwarePicker.tests.tsx index 43c80ed578..b79d4fa2d0 100644 --- a/frontend/components/CommandPalette/components/SoftwarePicker.tests.tsx +++ b/frontend/components/CommandPalette/components/SoftwarePicker.tests.tsx @@ -1,11 +1,16 @@ import React from "react"; import { waitFor } from "@testing-library/react"; +import { Command } from "cmdk"; import { createCustomRenderer } from "test/test-utils"; import softwareAPI from "services/entities/software"; +import { createMockSoftwareTitle } from "__mocks__/softwareMock"; import SoftwarePicker from "./SoftwarePicker"; +// cmdk uses scrollIntoView which JSDOM doesn't implement. +Element.prototype.scrollIntoView = jest.fn(); + jest.mock("services/entities/software", () => ({ __esModule: true, default: { getSoftwareTitles: jest.fn() }, @@ -14,6 +19,9 @@ jest.mock("services/entities/software", () => ({ const mockedSoftware = softwareAPI as jest.Mocked; const renderPicker = createCustomRenderer({ withBackendMock: true }); +// Picker renders Command.Item, which needs a Command root in context. +const renderPickerInCommand = (ui: React.ReactElement) => + renderPicker({ui}); beforeEach(() => { mockedSoftware.getSoftwareTitles.mockReset(); @@ -120,6 +128,40 @@ describe("SoftwarePicker", () => { expect(await findByText(/^No software found\.$/)).toBeInTheDocument(); }); + it("renders title labels via getDisplayedSoftwareName (display_name and normalization)", async () => { + mockedSoftware.getSoftwareTitles.mockResolvedValue({ + count: 2, + counts_updated_at: null, + software_titles: [ + createMockSoftwareTitle({ + id: 1, + name: "Zoom.pkg", + display_name: "Zoom Workplace", + }), + createMockSoftwareTitle({ + id: 2, + name: "Microsoft.CompanyPortal", + }), + ], + meta: { has_next_results: false, has_previous_results: false }, + }); + + // Unique search value sidesteps React Query cache pollution from + // earlier empty-state tests under queryKey [..., ""]. + const { findByText, queryByText } = renderPickerInCommand( + + ); + + expect(await findByText("Zoom Workplace")).toBeInTheDocument(); + expect(await findByText("Company Portal")).toBeInTheDocument(); + expect(queryByText("Zoom.pkg")).not.toBeInTheDocument(); + expect(queryByText("Microsoft.CompanyPortal")).not.toBeInTheDocument(); + }); + it("library empty state uses 'this fleet's library' for Unassigned", async () => { const { findByText } = renderPicker( {titles.map((title) => { - const label = title.display_name || title.name; + const label = getDisplayedSoftwareName(title.name, title.display_name); const typeLabel = formatSoftwareType(title); const installerProps = getInstallerProps(title); return ( diff --git a/frontend/docs/patterns.md b/frontend/docs/patterns.md index fa0ef0c33c..57fdbc5962 100644 --- a/frontend/docs/patterns.md +++ b/frontend/docs/patterns.md @@ -146,6 +146,37 @@ export default { } ``` +### Display names for software titles + +Software titles have two fields that look like a name: + +- `name` — the raw title from the installer/package metadata (e.g. `Microsoft.CompanyPortal`) +- `display_name` — an optional custom name set per fleet by an admin + +**Never render `name` directly in the UI.** Always route software names through +`getDisplayedSoftwareName(name, display_name)` from `pages/SoftwarePage/helpers.tsx`. +It prefers `display_name`, normalizes known awkward titles (e.g. +`microsoft.companyportal` → `Company Portal`), and falls back to a sensible +default. This applies everywhere a software title is shown: table rows, dropdown +options, modal text, activity feed entries, automation summaries, etc. + +```tsx +// good +label: getDisplayedSoftwareName(title.name, title.display_name), + +// bad — misses display_name and the WELL_KNOWN_SOFTWARE_TITLES normalization +label: title.name, + +// also bad — misses the WELL_KNOWN_SOFTWARE_TITLES normalization +label: title.display_name || title.name, +``` + +The same rule applies to any object shape that carries both fields +(`ISoftwareTitle`, `ISoftwarePackage`, `IAppStoreApp`, `IHostSoftware`, +`IPolicySoftwareToInstall`, etc.). The `ISoftwareTitle.name` JSDoc states the +expectation: "All software names displayed by UI is ran through +getDisplayedSoftwareName." + ## Components ### React functional components diff --git a/frontend/pages/SoftwarePage/helpers.tests.tsx b/frontend/pages/SoftwarePage/helpers.tests.tsx index c214f4a052..bf8c62f1f8 100644 --- a/frontend/pages/SoftwarePage/helpers.tests.tsx +++ b/frontend/pages/SoftwarePage/helpers.tests.tsx @@ -173,6 +173,14 @@ describe("getDisplayedSoftwareName", () => { expect(getDisplayedSoftwareName("Some App", "")).toBe("Some App"); }); + it("treats a whitespace-only display_name as absent and falls back to name", () => { + expect(getDisplayedSoftwareName("Some App", " ")).toBe("Some App"); + }); + + it("returns the default when display_name and name are both whitespace-only", () => { + expect(getDisplayedSoftwareName(" ", " ")).toBe("Software"); + }); + it("returns a default when neither name nor display_name is provided", () => { expect(getDisplayedSoftwareName(undefined, undefined)).toBe("Software"); expect(getDisplayedSoftwareName(null, null)).toBe("Software"); diff --git a/frontend/pages/SoftwarePage/helpers.tsx b/frontend/pages/SoftwarePage/helpers.tsx index 2885b00448..b42f4324cf 100644 --- a/frontend/pages/SoftwarePage/helpers.tsx +++ b/frontend/pages/SoftwarePage/helpers.tsx @@ -322,12 +322,13 @@ export const getDisplayedSoftwareName = ( name?: string | null, display_name?: string | null ): string => { - // 1. End-user custom name always wins. - if (display_name) { + // 1. End-user custom name always wins. Treat whitespace-only as absent so + // an inadvertent " " from the backend doesn't render a blank label. + if (display_name?.trim()) { return display_name; } - if (name) { + if (name?.trim()) { // 2. Normalize known titles only from the raw name. const key = name.toLowerCase(); if (WELL_KNOWN_SOFTWARE_TITLES[key]) { diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx index 2d3b3916b5..bd14fd3e09 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx @@ -20,6 +20,7 @@ import { import { IMunkiIssuesAggregate } from "interfaces/macadmins"; import { IPolicy } from "interfaces/policy"; import { SoftwareAggregateStatus } from "interfaces/software"; +import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; import { HOSTS_QUERY_PARAMS, @@ -352,11 +353,10 @@ const HostsFilterBlock = ({ if (!softwareDetails) return null; const { name, display_name, version } = softwareDetails; - let label = display_name || name; + let label = getDisplayedSoftwareName(name, display_name); if (version) { label += ` ${version}`; } - label = label.trim() || "Unknown software"; const clearParams = [ "software_id", diff --git a/frontend/pages/hosts/details/DeviceUserPage/components/SettingUpYourDevice/SetupStatusTable/SetupStatusTableConfig.tsx b/frontend/pages/hosts/details/DeviceUserPage/components/SettingUpYourDevice/SetupStatusTable/SetupStatusTableConfig.tsx index 0e8a46a7f2..f85a306c90 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/components/SettingUpYourDevice/SetupStatusTable/SetupStatusTableConfig.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/components/SettingUpYourDevice/SetupStatusTable/SetupStatusTableConfig.tsx @@ -3,6 +3,7 @@ import React from "react"; import { CellProps, Column } from "react-table"; import { ISetupStep } from "interfaces/setup"; +import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; import SetupSoftwareProcessCell from "components/TableContainer/DataTable/SetupSoftwareProcessCell"; import SetupSoftwareStatusCell from "components/TableContainer/DataTable/SetupSoftwareStatusCell"; @@ -22,7 +23,7 @@ const generateColumnConfigs = (): ISetupStatusTableConfig[] => [ if (type === "software_install") { return ( ); diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx index 4d75a6a6b2..ed336733da 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx @@ -400,7 +400,7 @@ const InstallStatusCell = ({ !!software.app_store_app && isAndroid(software.app_store_app.platform); const lastInstall = getLastInstall(software); // TODO (back end bug fix) - `software.app_store_app.last_install sometimes coming back `null` for VPP apps, currently falls back to displaying the `InventoryVersionsModal` const lastUninstall = getLastUninstall(software); - const softwarePackageName = getSoftwarePackageName(software); // @RachelElysia I renamed this function and the variable name its return value is set to here because it is looking at the software_package.name, which has a suffix like ".pkg". software.name has the more human-readable version. Not sure how else this data is being used so I am not going to refactor anything. Please update if needed. + const softwarePackageName = getSoftwarePackageName(software); const displayStatus = software.ui_status; if (displayStatus === "uninstalled" || displayStatus === "never_ran_script") { diff --git a/frontend/pages/policies/ManagePoliciesPage/helpers.tests.tsx b/frontend/pages/policies/ManagePoliciesPage/helpers.tests.tsx index 89c77edc80..53486c99e3 100644 --- a/frontend/pages/policies/ManagePoliciesPage/helpers.tests.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/helpers.tests.tsx @@ -334,6 +334,17 @@ describe("getAutomationsForPolicy", () => { expect(result[0].name).toBe("Chrome.app"); }); + it("normalizes known awkward titles via getDisplayedSoftwareName", () => { + const result = getAutomationsForPolicy({ + ...basePolicy, + install_software: { + name: "Microsoft.CompanyPortal", + software_title_id: 42, + }, + }); + expect(result[0].name).toBe("Company Portal"); + }); + it("returns script automation with file name", () => { const result = getAutomationsForPolicy({ ...basePolicy, diff --git a/frontend/pages/policies/ManagePoliciesPage/helpers.tsx b/frontend/pages/policies/ManagePoliciesPage/helpers.tsx index f66070efa8..f56af863e2 100644 --- a/frontend/pages/policies/ManagePoliciesPage/helpers.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/helpers.tsx @@ -1,6 +1,7 @@ import React from "react"; import { IPolicyStats, OtherAutomationType } from "interfaces/policy"; +import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; import { IInstallSoftwareFormData } from "./components/InstallSoftwareModal/InstallSoftwareModal"; import { IPolicyRunScriptFormData } from "./components/PolicyRunScriptModal/PolicyRunScriptModal"; @@ -47,8 +48,10 @@ export const getAutomationsForPolicy = ( if (policy.install_software) { automations.push({ type: "software", - name: - policy.install_software.display_name || policy.install_software.name, + name: getDisplayedSoftwareName( + policy.install_software.name, + policy.install_software.display_name + ), softwareTitleId: policy.install_software.software_title_id, iconUrl: policy.install_software.icon_url, }); diff --git a/frontend/pages/policies/components/PatchAutomationCta/PatchAutomationCta.tests.tsx b/frontend/pages/policies/components/PatchAutomationCta/PatchAutomationCta.tests.tsx index 818a83702e..3549edae98 100644 --- a/frontend/pages/policies/components/PatchAutomationCta/PatchAutomationCta.tests.tsx +++ b/frontend/pages/policies/components/PatchAutomationCta/PatchAutomationCta.tests.tsx @@ -60,6 +60,45 @@ describe("PatchAutomationCta", () => { ).toBeInTheDocument(); }); + it("prefers patch_software.display_name over name in the label", () => { + renderWithAppContext( + + ); + + expect( + screen.getByText(/Automatically patch Company Portal \(Corp\)/) + ).toBeInTheDocument(); + }); + + it("normalizes well-known patch_software names when display_name is absent", () => { + renderWithAppContext( + + ); + + expect( + screen.getByText(/Automatically patch Company Portal/) + ).toBeInTheDocument(); + }); + it("calls onAddAutomation when the button is clicked", async () => { const user = userEvent.setup(); const onAddAutomation = jest.fn(); diff --git a/frontend/pages/policies/components/PatchAutomationCta/PatchAutomationCta.tsx b/frontend/pages/policies/components/PatchAutomationCta/PatchAutomationCta.tsx index 5e88c702b0..1b494c754e 100644 --- a/frontend/pages/policies/components/PatchAutomationCta/PatchAutomationCta.tsx +++ b/frontend/pages/policies/components/PatchAutomationCta/PatchAutomationCta.tsx @@ -5,6 +5,7 @@ import { IPolicy } from "interfaces/policy"; import Button from "components/buttons/Button"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import Icon from "components/Icon"; +import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; const baseClass = "patch-automation-cta"; @@ -26,22 +27,21 @@ const PatchAutomationCta = ({ isAddingAutomation, }: IPatchAutomationCtaProps): JSX.Element | null => { const isPatchPolicy = storedPolicy.type === "patch"; - const hasPatchSoftware = !!storedPolicy.patch_software; const hasSoftwareAutomation = !!storedPolicy.install_software; if ( !isPatchPolicy || - !hasPatchSoftware || + !storedPolicy.patch_software || hasSoftwareAutomation || !canEditPolicy ) { return null; } - const patchSoftwareName = - storedPolicy.patch_software?.display_name || - storedPolicy.patch_software?.name || - ""; + const patchSoftwareName = getDisplayedSoftwareName( + storedPolicy.patch_software.name, + storedPolicy.patch_software.display_name + ); return (
diff --git a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx index f55ca74e98..179d8bb1e8 100644 --- a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx +++ b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx @@ -30,6 +30,7 @@ import { getTicketOrWebhookInfo, getTicketOrWebhookLabel, } from "pages/policies/helpers"; +import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; import { IPolicyAutomationUpdate } from "pages/policies/hooks"; @@ -218,7 +219,7 @@ const PolicyAutomationsFields = forwardRef< const softwareOptions: CustomOptionType[] = useMemo( () => (softwareTitlesData?.software_titles ?? []).map((t) => ({ - label: t.name, + label: getDisplayedSoftwareName(t.name, t.display_name), value: String(t.id), helpText: generateSoftwareOptionHelpText(t), })), diff --git a/frontend/pages/policies/components/PolicyAutomationsList/PolicyAutomationsList.tests.tsx b/frontend/pages/policies/components/PolicyAutomationsList/PolicyAutomationsList.tests.tsx index 1cc086fa1d..a172a1b959 100644 --- a/frontend/pages/policies/components/PolicyAutomationsList/PolicyAutomationsList.tests.tsx +++ b/frontend/pages/policies/components/PolicyAutomationsList/PolicyAutomationsList.tests.tsx @@ -61,6 +61,43 @@ describe("PolicyAutomationsList", () => { expect(screen.queryByText("No automations")).not.toBeInTheDocument(); }); + it("prefers install_software.display_name over name", () => { + render( + + ); + + expect(screen.getByText("Zoom Workplace")).toBeInTheDocument(); + expect(screen.queryByText("Zoom.pkg")).not.toBeInTheDocument(); + }); + + it("normalizes well-known software names via the display helper", () => { + render( + + ); + + expect(screen.getByText("Company Portal")).toBeInTheDocument(); + expect( + screen.queryByText("Microsoft.CompanyPortal") + ).not.toBeInTheDocument(); + }); + it("forwards the custom software icon_url to SoftwareIcon", () => { const iconUrl = ENDPOINTS.SOFTWARE_ICON(42); render( diff --git a/frontend/pages/policies/components/PolicyAutomationsList/PolicyAutomationsList.tsx b/frontend/pages/policies/components/PolicyAutomationsList/PolicyAutomationsList.tsx index 2156587ff5..ec0e9edbb2 100644 --- a/frontend/pages/policies/components/PolicyAutomationsList/PolicyAutomationsList.tsx +++ b/frontend/pages/policies/components/PolicyAutomationsList/PolicyAutomationsList.tsx @@ -8,6 +8,7 @@ import { getPathWithQueryParams } from "utilities/url"; import Graphic from "components/Graphic"; import { GraphicNames } from "components/graphics"; import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; +import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; const baseClass = "policy-automations-list"; @@ -44,8 +45,12 @@ const PolicyAutomationsList = ({ const automationRows: IAutomationDisplayRow[] = []; if (storedPolicy.install_software) { + const displayedName = getDisplayedSoftwareName( + storedPolicy.install_software.name, + storedPolicy.install_software.display_name + ); automationRows.push({ - name: storedPolicy.install_software.name, + name: displayedName, type: "Software", isSoftware: true, iconUrl: storedPolicy.install_software.icon_url, @@ -56,7 +61,7 @@ const PolicyAutomationsList = ({ { fleet_id: storedPolicy.team_id } ), sortOrder: 0, - sortName: storedPolicy.install_software.name.toLowerCase(), + sortName: displayedName.toLowerCase(), }); }