Fleet UI: Ability to edit software display names (#34872)

This commit is contained in:
RachelElysia 2025-11-07 09:59:30 -05:00 committed by GitHub
parent 1d7db85d66
commit 3efeeb1ad0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 413 additions and 135 deletions

3
.gitignore vendored
View file

@ -30,6 +30,9 @@ frontend/coverage
# typescript generated test files
tmp/
# test debug files
debug.test*
# operating system artifacts
.DS_Store

View file

@ -0,0 +1 @@
- Fleet UI: Added ability to change software display names

View file

@ -204,6 +204,7 @@ export const DEFAULT_INSTALLED_VERSION = {
const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = {
id: 1,
name: "mock software.app",
display_name: "Mock Software",
icon_url: null,
software_package: createMockHostSoftwarePackage(),
app_store_app: null,

View file

@ -73,6 +73,7 @@ export const createMockSoftwareVulnerability = (
const DEFAULT_SOFTWARE_VERSION_MOCK: ISoftwareVersion = {
id: 1,
name: "test.app",
display_name: "Test App",
version: "1.2.3",
bundle_identifier: "com.test.Desktop",
source: "apps",

View file

@ -366,7 +366,9 @@ export const SoftwareInstallDetailsModal = ({
<div className={`${baseClass}__modal-content`}>
<StatusMessage
installResult={installResultWithHostDisplayName}
softwareName={hostSoftware?.name || "Software"} // will always be defined at this point
softwareName={
hostSoftware?.display_name || hostSoftware?.name || "Software"
} // will always be defined at this point
isMyDevicePage={!!deviceAuthToken}
contactUrl={contactUrl}
/>

View file

@ -28,6 +28,7 @@ export type ISupportedGraphicNames = Extract<
>;
interface IFileUploaderProps {
label?: React.ReactNode;
graphicName: ISupportedGraphicNames | ISupportedGraphicNames[];
message: React.ReactNode;
title?: string;
@ -79,6 +80,7 @@ interface IFileUploaderProps {
* A component that encapsulates the UI for uploading a file and a file selected.
*/
export const FileUploader = ({
label,
graphicName: graphicNames,
message,
title,
@ -132,6 +134,11 @@ export const FileUploader = ({
}
};
const renderLabel = () => {
return label ? (
<div className={`${baseClass}__label form-field__label`}>{label}</div>
) : null;
};
const renderGraphics = () => {
const graphicNamesArr =
typeof graphicNames === "string" ? [graphicNames] : graphicNames;
@ -249,22 +256,25 @@ export const FileUploader = ({
};
return (
<Card color="grey" className={classes}>
{fileDetails ? (
<FileDetails
graphicNames={graphicNames}
fileDetails={fileDetails}
canEdit={canEdit}
onDeleteFile={onDeleteFile}
onFileSelect={onFileSelect}
accept={accept}
gitopsCompatible={gitopsCompatible}
gitOpsModeEnabled={gitOpsModeEnabled}
/>
) : (
renderFileUploader()
)}
</Card>
<div className={`${baseClass}__wrapper form-field`}>
{renderLabel()}
<Card color="grey" className={classes}>
{fileDetails ? (
<FileDetails
graphicNames={graphicNames}
fileDetails={fileDetails}
canEdit={canEdit}
onDeleteFile={onDeleteFile}
onFileSelect={onFileSelect}
accept={accept}
gitopsCompatible={gitopsCompatible}
gitOpsModeEnabled={gitOpsModeEnabled}
/>
) : (
renderFileUploader()
)}
</Card>
</div>
);
};

View file

@ -8,7 +8,7 @@
padding: $pad-xlarge $pad-large;
font-size: $x-small;
text-align: center;
.content-wrapper {
@include content-wrapper();
}
@ -42,7 +42,8 @@
// we handle the padding in the label so the entire button is clickable
padding: 0;
label, span {
label,
span {
padding: $pad-small $pad-medium;
display: flex;
align-items: center;

View file

@ -129,7 +129,10 @@ const InstallIconWithTooltip = ({
};
interface ISoftwareNameCellProps {
/** Used to key default software icon and name displayed if no display_name */
name: string;
/** Overrides name for display */
display_name?: string;
source?: string;
/** pass in a `path` that this cell will link to */
path?: string;
@ -144,6 +147,7 @@ interface ISoftwareNameCellProps {
const SoftwareNameCell = ({
name,
display_name,
source,
path,
router,
@ -156,7 +160,9 @@ const SoftwareNameCell = ({
const icon = <SoftwareIcon name={name} source={source} url={iconUrl} />;
// My device page > Software fake link as entire row opens a modal
if (pageContext === "deviceUser" && !isSelfService) {
return <LinkCell tooltipTruncate prefix={icon} value={name} />;
return (
<LinkCell tooltipTruncate prefix={icon} value={display_name || name} />
);
}
// Non-clickable cell if no router/path (e.g. My device page > SelfService)
@ -165,7 +171,7 @@ const SoftwareNameCell = ({
<div className={baseClass}>
<TooltipTruncatedTextCell
prefix={icon}
value={name}
value={display_name || name}
className="software-name"
/>
</div>
@ -185,7 +191,7 @@ const SoftwareNameCell = ({
tooltipTruncate
customOnClick={onClickSoftware}
prefix={icon}
value={name}
value={display_name || name}
suffix={
hasInstaller ? (
<InstallIconWithTooltip

View file

@ -7,8 +7,10 @@ export default PropTypes.shape({
persistOnPageChange: PropTypes.bool,
});
export type IAlertType = "success" | "error" | "warning-filled";
export interface INotification {
alertType: "success" | "error" | "warning-filled" | null;
alertType: IAlertType | null;
isVisible: boolean;
message: JSX.Element | string | null;
persistOnPageChange?: boolean;

View file

@ -35,6 +35,7 @@ export interface IGetSoftwareByIdResponse {
export interface ISoftware {
id: number;
name: string; // e.g., "Figma.app"
display_name?: string; // e.g. "Figma for Desktop"
version: string; // e.g., "2.1.11"
bundle_identifier?: string | null; // e.g., "com.figma.Desktop"
application_id?: string | null; // e.g., "us.zoom.videomeetings" for Android apps
@ -90,6 +91,7 @@ export interface ISoftwareAppStoreAppStatus {
export interface ISoftwarePackage {
name: string;
display_name?: string;
title_id: number;
url: string;
version: string;
@ -118,6 +120,7 @@ export const isSoftwarePackage = (
export interface IAppStoreApp {
name: string;
display_name?: string;
app_store_id: string; // API returns this as a string
latest_version: string;
created_at: string;
@ -142,6 +145,7 @@ export interface IAppStoreApp {
export interface ISoftwareTitle {
id: number;
name: string;
display_name?: string;
icon_url: string | null;
versions_count: number;
source: SoftwareSource;
@ -157,6 +161,7 @@ export interface ISoftwareTitle {
export interface ISoftwareTitleDetails {
id: number;
name: string;
display_name?: string;
icon_url: string | null;
software_package: ISoftwarePackage | null;
app_store_app: IAppStoreApp | null;
@ -186,6 +191,7 @@ export interface ISoftwareVulnerability {
export interface ISoftwareVersion {
id: number;
name: string; // e.g., "Figma.app"
display_name?: string; // e.g. "Figma for Desktop"
version: string; // e.g., "2.1.11"
bundle_identifier?: string; // e.g., "com.figma.Desktop"
source: SoftwareSource;
@ -493,6 +499,7 @@ export interface ISoftwareInstallVersion {
export interface IHostSoftwarePackage {
name: string;
display_name?: string;
self_service: boolean;
icon_url: string | null;
version: string;
@ -504,6 +511,7 @@ export interface IHostSoftwarePackage {
}
export interface IHostAppStoreApp {
display_name?: string;
app_store_id: string;
self_service: boolean;
icon_url: string;
@ -516,6 +524,7 @@ export interface IHostAppStoreApp {
export interface IHostSoftware {
id: number;
name: string;
display_name?: string;
icon_url: string | null;
software_package: IHostSoftwarePackage | null;
app_store_app: IHostAppStoreApp | null;

View file

@ -63,10 +63,15 @@ const generateTableConfig = (
disableSortBy: true,
accessor: "name",
Cell: (cellProps: ITableStringCellProps) => {
const { name, source, icon_url } = cellProps.row.original;
const { name, display_name, source, icon_url } = cellProps.row.original;
return (
<SoftwareNameCell name={name} source={source} iconUrl={icon_url} />
<SoftwareNameCell
name={name}
display_name={display_name}
source={source}
iconUrl={icon_url}
/>
);
},
sortType: "caseInsensitive",

View file

@ -55,7 +55,7 @@ export const SummaryCard = ({
}: ISummaryCardProps) => (
<Card borderRadiusSize="xxlarge" className={`${baseClass}__summary-section`}>
<SoftwareDetailsSummary
title={osVersion.name}
displayName={osVersion.name}
hostCount={osVersion.hosts_count}
countsUpdatedAt={countsUpdatedAt}
queryParams={{

View file

@ -1,5 +1,5 @@
import React from "react";
import { screen } from "@testing-library/react";
import { screen, fireEvent } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import {
createMockSoftwarePackage,
@ -24,6 +24,7 @@ const MOCK_PROPS = {
source: software.source,
currentIconUrl: null,
name: software.name,
titleName: software.name,
countsUpdatedAt: "2025-09-03T12:00:00Z",
},
};
@ -62,4 +63,31 @@ describe("EditIconModal", () => {
});
// Note: Rely on QA Wolf for E2e testing of file upload, preview, save, and remove icon
describe("Display name tests", () => {
it("shows the Display name input with correct default value", () => {
const render = createCustomRenderer({ withBackendMock: true });
render(<EditIconModal {...MOCK_PROPS} />);
// Should default to blank if previewInfo.titleName === previewInfo.name
const displayNameInput = screen.getByLabelText("Display name");
expect(displayNameInput).toBeInTheDocument();
expect(displayNameInput).toHaveValue("");
});
it("pre-fills Display name if previewInfo.name has been modified", () => {
const MODIFIED_PROPS = {
...MOCK_PROPS,
previewInfo: {
...MOCK_PROPS.previewInfo,
name: "New Custom Name",
titleName: "Original Title Name",
},
};
const render = createCustomRenderer({ withBackendMock: true });
render(<EditIconModal {...MODIFIED_PROPS} />);
const displayNameInput = screen.getByLabelText("Display name");
expect(displayNameInput).toBeInTheDocument();
expect(displayNameInput).toHaveValue("New Custom Name");
});
});
});

View file

@ -2,13 +2,18 @@ import React, { useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
import { IAppStoreApp, ISoftwarePackage } from "interfaces/software";
import { IInputFieldParseTarget } from "interfaces/form_field";
import { NotificationContext } from "context/notification";
import { INotification } from "interfaces/notification";
import { getErrorReason } from "interfaces/errors";
import softwareAPI from "services/entities/software";
import mdmAppleAPI from "services/entities/mdm_apple";
import Modal from "components/Modal";
import ModalFooter from "components/ModalFooter";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import FileUploader from "components/FileUploader";
import TabNav from "components/TabNav";
import TabText from "components/TabText";
@ -58,9 +63,15 @@ const makeFileDetails = (
description: `Software icon • ${dimensions || "?"}x${dimensions || "?"} px`,
});
interface IIconFormData {
interface IFormData {
icon: File;
display_name?: string;
}
export interface ISoftwareDisplayNameFormData {
displayName?: string; // Edit Display name is in the edit icon modal
}
interface IFileDetails {
name: string;
description: string;
@ -84,7 +95,7 @@ type IconStatus = "customUpload" | "apiCustom" | "fallback";
*/
interface IconState {
previewUrl: string | null;
formData: IIconFormData | null;
formData: IFormData | null;
dimensions: number | null;
fileDetails: IFileDetails | null;
status: IconStatus;
@ -117,6 +128,8 @@ interface IEditIconModalProps {
currentIconUrl: string | null;
/** Name used in preview UI but also for FMA default icon matching */
name: string;
/** Default title name used to check if name has been modified */
titleName: string;
countsUpdatedAt?: string;
};
}
@ -132,7 +145,7 @@ const EditIconModal = ({
installerType,
previewInfo,
}: IEditIconModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const { renderFlash, renderMultiFlash } = useContext(NotificationContext);
const isSoftwarePackage = installerType === "package";
@ -141,10 +154,15 @@ const EditIconModal = ({
!!previewInfo.currentIconUrl &&
previewInfo.currentIconUrl.startsWith("/api/");
// Encapsulates icon preview/upload/edit state
// Unmodified names default to empty Display name field
const hasNameBeenModified = previewInfo.titleName !== previewInfo.name;
const defaultName = hasNameBeenModified ? previewInfo.name : "";
// Encapsulates software name and icon preview/upload/edit state
const [displayName, setDisplayName] = useState(defaultName);
const [iconState, setIconState] = useState<IconState>(defaultIconState);
const [previewTabIndex, setPreviewTabIndex] = useState(0);
const [isUpdatingIcon, setIsUpdatingIcon] = useState(false);
const [isUpdatingSoftwareInfo, setIsUpdatingSoftwareInfo] = useState(false);
/** Shows loading spinner only if a custom icon and its information is loading from API */
const [isFirstLoadWithCustomIcon, setIsFirstLoadWithCustomIcon] = useState(
shouldFetchCustomIcon
@ -162,6 +180,15 @@ const EditIconModal = ({
iconState.status === "fallback" &&
!iconState.formData;
const canSaveIcon = isCustomUpload || isRemovedCustom;
// Determine if any changes have been made to allow enabling Save button
const canSaveDisplayName =
(hasNameBeenModified && displayName === "") || // user cleared an override display name
(!hasNameBeenModified && displayName !== "") || // user set an override display name
(hasNameBeenModified &&
displayName !== "" &&
displayName !== previewInfo.name); // user changed override display name
// Ensures Save button is only enabled when icon or name has been changed
const canSaveForm = canSaveIcon || canSaveDisplayName;
// Sets state after fetching current API custom icon
const setCurrentApiCustomIcon = (
@ -171,7 +198,7 @@ const EditIconModal = ({
) =>
setIconState({
previewUrl,
formData: { icon: file },
formData: { icon: file, display_name: displayName },
dimensions: width,
fileDetails: makeFileDetails(file, width),
status: "apiCustom",
@ -229,6 +256,19 @@ const EditIconModal = ({
onExit();
};
const onInputChange = ({ value }: IInputFieldParseTarget) => {
setDisplayName((value as string) || "");
// If you want live update in the formData:
setIconState((prev) =>
prev.formData
? {
...prev,
formData: { ...prev.formData, display_name: value as string },
}
: prev
);
};
const onFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
@ -359,7 +399,7 @@ const EditIconModal = ({
className={`${baseClass}__preview-card__fleet`}
>
<SoftwareDetailsSummary
title={name}
displayName={displayName || name}
name={name}
type={type}
source={source}
@ -460,14 +500,14 @@ const EditIconModal = ({
// Known limitation: we cannot see VPP app icons as the fallback when a custom icon
// is set as VPP icon is not returned by the API if a custom icon is returned
<SoftwareIcon
name={previewInfo.name}
name={displayName || previewInfo.name}
source={previewInfo.source}
url={isSoftwarePackage ? undefined : software.icon_url} // fallback PNG icons only exist for VPP apps
uploadedAt={iconUploadedAt}
/>
)}
<div className={`${baseClass}__self-service-preview-name`}>
<TooltipTruncatedText value={previewInfo.name} />
<TooltipTruncatedText value={displayName || previewInfo.name} />
</div>
</div>
</Card>
@ -479,7 +519,22 @@ const EditIconModal = ({
const renderForm = () => (
<>
<InputField
label="Display name"
onChange={onInputChange}
name="displayName"
value={displayName}
parseTarget
helpText={
<>
Optional. If left blank, Fleet will use{" "}
<strong>{previewInfo.name}</strong>.
</>
}
autofocus
/>
<FileUploader
label="Icon"
canEdit
onDeleteFile={onDeleteFile}
graphicName="file-png"
@ -511,29 +566,97 @@ const EditIconModal = ({
);
const onClickSave = async () => {
setIsUpdatingIcon(true);
setIsUpdatingSoftwareInfo(true);
try {
if (!iconState.formData?.icon) {
await softwareAPI.deleteSoftwareIcon(softwareId, teamIdForApi);
renderFlash(
"success",
<>
Successfully removed icon from <b>{software?.name}</b>.
</>
);
} else {
await softwareAPI.editSoftwareIcon(
softwareId,
teamIdForApi,
iconState.formData
);
renderFlash(
"success",
<>
Successfully edited <b>{previewInfo.name}</b>.
</>
);
const notifications: INotification[] = [];
// Process icon change
try {
if (!iconState.formData?.icon) {
await softwareAPI.deleteSoftwareIcon(softwareId, teamIdForApi);
notifications.push({
id: "icon-removed",
alertType: "success",
isVisible: true,
message: (
<>
Successfully removed icon from <b>{software?.name}</b>.
</>
),
persistOnPageChange: false,
});
} else {
await softwareAPI.editSoftwareIcon(
softwareId,
teamIdForApi,
iconState.formData
);
notifications.push({
id: "icon-edited",
alertType: "success",
isVisible: true,
message: (
<>
Successfully edited <b>{previewInfo.name}</b>.
</>
),
persistOnPageChange: false,
});
}
} catch (e) {
const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE;
notifications.push({
id: "icon-error",
alertType: "error",
isVisible: true,
message: errorMessage,
persistOnPageChange: false,
});
}
// Process display name change
if (displayName !== previewInfo.name) {
try {
await (installerType === "package"
? softwareAPI.editSoftwarePackage({
data: { displayName },
softwareId,
teamId: teamIdForApi,
})
: mdmAppleAPI.editVppApp(softwareId, teamIdForApi, {
displayName,
}));
notifications.push({
id: "name-edited",
alertType: "success",
isVisible: true,
message: (
<>
Successfully renamed <b>{previewInfo.name}</b> to{" "}
<b>{displayName}</b>.
</>
),
persistOnPageChange: false,
});
} catch (e) {
const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE;
notifications.push({
id: "name-error",
alertType: "error",
isVisible: true,
message: errorMessage,
persistOnPageChange: false,
});
}
}
// Show all gathered messages
if (notifications.length > 1) {
renderMultiFlash({ notifications });
} else if (notifications.length === 1) {
renderFlash(notifications[0].alertType, notifications[0].message);
}
refetchSoftwareTitle();
setIconUploadedAt(new Date().toISOString());
onExitEditIconModal();
@ -541,7 +664,7 @@ const EditIconModal = ({
const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE;
renderFlash("error", errorMessage);
} finally {
setIsUpdatingIcon(false);
setIsUpdatingSoftwareInfo(false);
}
};
@ -562,8 +685,8 @@ const EditIconModal = ({
<Button
type="submit"
onClick={onClickSave}
isLoading={isUpdatingIcon}
disabled={!canSaveIcon || isUpdatingIcon}
isLoading={isUpdatingSoftwareInfo}
disabled={!canSaveForm || isUpdatingSoftwareInfo}
>
Save
</Button>

View file

@ -76,7 +76,7 @@ const SoftwareSummaryCard = ({
<>
<Card borderRadiusSize="xxlarge" className={baseClass}>
<SoftwareDetailsSummary
title={title.name}
displayName={title.display_name || title.name}
type={formatSoftwareType(title)}
versions={title.versions?.length ?? 0}
hostCount={title.hosts_count}
@ -119,7 +119,8 @@ const SoftwareSummaryCard = ({
isSoftwarePackage(softwareInstaller) ? "package" : "vpp"
}
previewInfo={{
name: title.name,
name: softwareInstaller.display_name || title.name,
titleName: title.name,
type: formatSoftwareType(title),
source: title.source,
currentIconUrl: title.icon_url,

View file

@ -54,12 +54,14 @@ describe("SoftwareTitleDetailsPage helpers", () => {
const softwareTitle: ISoftwareTitleDetails = {
id: 1,
name: "Test Software",
display_name: "Test App",
icon_url: "https://example.com/icon.png",
versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }],
software_package: null,
app_store_app: {
app_store_id: "1",
name: "Test App",
display_name: "Test App",
created_at: "2020-01-01T00:00:00.000Z",
latest_version: "1.0.1",
platform: "darwin",

View file

@ -85,6 +85,7 @@ const getSoftwareNameCellData = (
return {
name: softwareTitle.name,
displayName: softwareTitle.display_name,
source: softwareTitle.source,
path: softwareTitleDetailsPath,
hasInstaller: hasInstaller && !isAllTeams,
@ -115,6 +116,7 @@ const generateTableHeaders = (
return (
<SoftwareNameCell
name={nameCellData.name}
display_name={nameCellData.displayName}
source={nameCellData.source}
path={nameCellData.path}
router={router}

View file

@ -43,7 +43,7 @@ const generateTableHeaders = (
disableSortBy: false,
accessor: "name",
Cell: (cellProps: ITableStringCellProps) => {
const { id, name, source } = cellProps.row.original;
const { id, name, display_name, source } = cellProps.row.original;
const softwareVersionDetailsPath = getPathWithQueryParams(
PATHS.SOFTWARE_VERSION_DETAILS(id.toString()),
@ -55,6 +55,7 @@ const generateTableHeaders = (
return (
<SoftwareNameCell
name={name}
display_name={display_name}
source={source}
// iconUrl does not exist on ISoftwareVersion
path={softwareVersionDetailsPath}

View file

@ -179,7 +179,9 @@ const SoftwareVersionDetailsPage = ({
className={`${baseClass}__summary-section`}
>
<SoftwareDetailsSummary
title={`${softwareVersion.name}, ${softwareVersion.version}`}
displayName={`${
softwareVersion.display_name || softwareVersion.name
}, ${softwareVersion.version}`}
type={formatSoftwareType(softwareVersion)}
hostCount={hostsCount}
queryParams={{

View file

@ -16,6 +16,7 @@ import {
import DataSet from "components/DataSet";
import LastUpdatedHostCount from "components/LastUpdatedHostCount";
import TooltipWrapper from "components/TooltipWrapper";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import CustomLink from "components/CustomLink";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
@ -28,7 +29,10 @@ import OSIcon from "../../icons/OSIcon";
const baseClass = "software-details-summary";
interface ISoftwareDetailsSummaryProps {
title: string;
/** Name displayed in UI */
displayName: string;
/** Name is keyed for fallback icon */
name?: string;
type?: string;
hostCount?: number;
countsUpdatedAt?: string;
@ -36,7 +40,6 @@ interface ISoftwareDetailsSummaryProps {
* Optional as isPreview mode doesn't have host count/link
*/
queryParams?: QueryParams;
name?: string;
source?: string;
versions?: number;
iconUrl?: string | null;
@ -52,7 +55,7 @@ interface ISoftwareDetailsSummaryProps {
}
const SoftwareDetailsSummary = ({
title,
displayName,
type,
hostCount,
countsUpdatedAt,
@ -108,14 +111,14 @@ const SoftwareDetailsSummary = ({
)}
<dl className={`${baseClass}__info`}>
<h1>
{ROLLING_ARCH_LINUX_VERSIONS.includes(title) ? (
{ROLLING_ARCH_LINUX_VERSIONS.includes(displayName) ? (
// wrap a tooltip around the "rolling" suffix
<>
{title.slice(0, -8)}
{displayName.slice(0, -8)}
<TooltipWrapperArchLinuxRolling />
</>
) : (
title
<TooltipTruncatedText value={displayName} />
)}
{onClickEditIcon && (
<div className={`${baseClass}__edit-icon`}>

View file

@ -10,6 +10,10 @@
&__info {
flex-grow: 1;
align-self: center;
.truncated-tooltip {
font-weight: $regular;
}
}
h1 {

View file

@ -613,7 +613,9 @@ const HostSoftwareLibrary = ({
details={{
hostDisplayName,
fleetInstallStatus: selectedHostSWIpaInstallDetails.status,
appName: selectedHostSWIpaInstallDetails.name,
appName:
selectedHostSWIpaInstallDetails.display_name ||
selectedHostSWIpaInstallDetails.name,
commandUuid:
selectedHostSWIpaInstallDetails.software_package?.last_install
?.install_uuid, // slightly redundant, see explanation in `SoftwareInstallDetailsModal
@ -646,7 +648,9 @@ const HostSoftwareLibrary = ({
details={{
fleetInstallStatus: selectedVPPInstallDetails.status,
hostDisplayName,
appName: selectedVPPInstallDetails.name,
appName:
selectedVPPInstallDetails.display_name ||
selectedVPPInstallDetails.name,
commandUuid: selectedVPPInstallDetails.commandUuid,
}}
hostSoftware={selectedVPPInstallDetails}

View file

@ -93,6 +93,7 @@ export const generateHostSWLibraryTableHeaders = ({
const {
id,
name,
display_name,
source,
icon_url,
app_store_app,
@ -114,6 +115,7 @@ export const generateHostSWLibraryTableHeaders = ({
return (
<SoftwareNameCell
name={name}
display_name={display_name}
source={source}
iconUrl={icon_url}
path={softwareTitleDetailsPath}

View file

@ -39,10 +39,11 @@ export const generateSoftwareTableHeaders = (): ISoftwareTableConfig[] => {
disableSortBy: false,
disableGlobalFilter: false,
Cell: (cellProps: ITableStringCellProps) => {
const { name, source, icon_url } = cellProps.row.original;
const { name, display_name, source, icon_url } = cellProps.row.original;
return (
<SoftwareNameCell
name={name}
display_name={display_name}
source={source}
iconUrl={icon_url}
pageContext="deviceUser"

View file

@ -63,6 +63,7 @@ export const generateSoftwareTableHeaders = ({
const {
id,
name,
display_name,
source,
app_store_app,
software_package,
@ -84,6 +85,7 @@ export const generateSoftwareTableHeaders = ({
return (
<SoftwareNameCell
name={name}
display_name={display_name}
source={source}
iconUrl={icon_url}
path={softwareTitleDetailsPath}

View file

@ -348,7 +348,7 @@ type IInstallStatusCellProps = {
};
const getSoftwarePackageName = (software: IHostSoftware) =>
software.software_package?.name;
software.software_package?.display_name || software.software_package?.name;
const resolveDisplayText = (
displayText: IStatusDisplayConfig["displayText"],
@ -455,7 +455,7 @@ const InstallStatusCell = ({
if (lastUninstall) {
if ("script_execution_id" in lastUninstall) {
onShowUninstallDetails({
softwareName: software.name || "",
softwareName: software.display_name || software.name || "",
softwarePackageName,
uninstallStatus: (software.status ||
"pending_uninstall") as SoftwareUninstallStatus,

View file

@ -641,7 +641,9 @@ const SoftwareSelfService = ({
details={{
hostDisplayName,
fleetInstallStatus: selectedHostSWIpaInstallDetails.status,
appName: selectedHostSWIpaInstallDetails.name,
appName:
selectedHostSWIpaInstallDetails.display_name ||
selectedHostSWIpaInstallDetails.name,
commandUuid:
selectedHostSWIpaInstallDetails.software_package?.last_install
?.install_uuid, // slightly redundant, see explanation in `SoftwareInstallDetailsModal
@ -672,7 +674,9 @@ const SoftwareSelfService = ({
details={{
fleetInstallStatus: selectedVPPInstallDetails.status,
hostDisplayName,
appName: selectedVPPInstallDetails.name,
appName:
selectedVPPInstallDetails.display_name ||
selectedVPPInstallDetails.name,
commandUuid: selectedVPPInstallDetails.commandUuid,
}}
hostSoftware={selectedVPPInstallDetails}

View file

@ -74,10 +74,11 @@ export const generateSoftwareTableHeaders = ({
disableSortBy: false,
disableGlobalFilter: false,
Cell: (cellProps: ITableStringCellProps) => {
const { name, source, icon_url } = cellProps.row.original;
const { name, display_name, source, icon_url } = cellProps.row.original;
return (
<SoftwareNameCell
name={name}
display_name={display_name}
source={source}
iconUrl={icon_url}
pageContext="deviceUser"

View file

@ -114,7 +114,7 @@ describe("SoftwareUpdateModal", () => {
expect(screen.queryByTestId("error-outline-icon")).toBeInTheDocument();
expect(screen.getByText(/New version of/i)).toBeInTheDocument();
expect(screen.getByText(/mock software.app/i)).toBeInTheDocument();
expect(screen.getByText(/Mock Software/i)).toBeInTheDocument();
expect(
screen.getByText(/Update the current version on/i)
).toBeInTheDocument();

View file

@ -89,11 +89,13 @@ const SoftwareUpdateModal = ({
id,
status,
name,
display_name,
installed_versions,
software_package,
app_store_app,
} = software;
const installerName = software_package?.name || "";
const installerName =
software_package?.display_name || software_package?.name || "";
const installerVersion = software_package?.version || app_store_app?.version;
const onClickUpdate = () => {
@ -114,7 +116,7 @@ const SoftwareUpdateModal = ({
hostDisplayName={hostDisplayName}
isDeviceUser={isDeviceUser}
softwareStatus={status}
softwareName={name}
softwareName={display_name || name}
installerName={installerName}
installerVersion={installerVersion}
/>

View file

@ -2,6 +2,7 @@ import { IMdmVppToken } from "interfaces/mdm";
import { ApplePlatform } from "interfaces/platform";
import { SoftwareCategory } from "interfaces/software";
import { ISoftwareVppFormData } from "pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm";
import { ISoftwareDisplayNameFormData } from "pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { listNamesFromSelectedLabels } from "components/TargetLabelSelector/TargetLabelSelector";
@ -56,6 +57,42 @@ export interface IUploadVppTokenReponse {
export type IRenewVppTokenResponse = IUploadVppTokenReponse;
const handleDisplayNameVppForm = (
formData: ISoftwareDisplayNameFormData,
teamId: number
): IEditVppAppPostBody => {
return {
self_service: false, // or some default as needed
team_id: teamId,
// you might not need categories/labels with this form
};
};
const handleEditVppForm = (
formData: ISoftwareVppFormData,
teamId: number
): IEditVppAppPostBody => {
const body: IEditVppAppPostBody = {
self_service: formData.selfService,
team_id: teamId,
};
if (formData.categories && formData.categories.length > 0) {
body.categories = formData.categories as SoftwareCategory[];
}
if (formData.targetType === "Custom") {
const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets);
if (formData.customTarget === "labelsIncludeAny") {
body.labels_include_any = selectedLabels;
} else {
body.labels_exclude_any = selectedLabels;
}
}
return body;
};
export default {
getAppleAPNInfo: () => {
const { MDM_APPLE_PNS } = endpoints;
@ -126,29 +163,17 @@ export default {
editVppApp: (
softwareId: number,
teamId: number,
formData: ISoftwareVppFormData
formData: ISoftwareVppFormData | ISoftwareDisplayNameFormData
) => {
const { EDIT_SOFTWARE_VPP } = endpoints;
const body: IEditVppAppPostBody = {
self_service: formData.selfService,
team_id: teamId,
};
// Add categories if present
if (formData.categories && formData.categories.length > 0) {
body.categories = formData.categories as SoftwareCategory[];
let body: IEditVppAppPostBody;
if ("displayName" in formData) {
// Handles Edit display name form only
body = handleDisplayNameVppForm(formData, teamId);
} else {
// Handles primary Edit VPP form
body = handleEditVppForm(formData as ISoftwareVppFormData, teamId);
}
if (formData.targetType === "Custom") {
const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets);
if (formData.customTarget === "labelsIncludeAny") {
body.labels_include_any = selectedLabels;
} else {
body.labels_exclude_any = selectedLabels;
}
}
return sendRequest("PATCH", EDIT_SOFTWARE_VPP(softwareId), body);
},

View file

@ -20,6 +20,7 @@ import {
} from "utilities/url";
import { IPackageFormData } from "pages/SoftwarePage/components/forms/PackageForm/PackageForm";
import { IEditPackageFormData } from "pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal";
import { ISoftwareDisplayNameFormData } from "pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal";
import { IAddFleetMaintainedData } from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage";
import { listNamesFromSelectedLabels } from "components/TargetLabelSelector/TargetLabelSelector";
@ -156,6 +157,54 @@ const ORDER_DIRECTION = "asc";
export const MAX_FILE_SIZE_MB = 3000;
export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const handleDisplayNameForm = (
data: ISoftwareDisplayNameFormData,
formData: FormData
) => {
formData.append("display_name", data.displayName || "");
};
const handleEditPackageForm = (
data: IEditPackageFormData,
formData: FormData,
orignalPackage: ISoftwarePackage
) => {
data.software && formData.append("software", data.software);
formData.append("self_service", data.selfService.toString());
formData.append("install_script", data.installScript);
formData.append("pre_install_query", data.preInstallQuery || "");
formData.append("post_install_script", data.postInstallScript || "");
formData.append("uninstall_script", data.uninstallScript || "");
if (data.categories) {
data.categories.forEach((category) => {
formData.append("categories", category);
});
}
// clear out labels if targetType is "All hosts"
if (data.targetType === "All hosts") {
if (orignalPackage.labels_include_any) {
formData.append("labels_include_any", "");
} else {
formData.append("labels_exclude_any", "");
}
}
// add custom labels if targetType is "Custom"
if (data.targetType === "Custom") {
const selectedLabels = listNamesFromSelectedLabels(data.labelTargets);
let labelKey = "";
if (data.customTarget === "labelsIncludeAny") {
labelKey = "labels_include_any";
} else {
labelKey = "labels_exclude_any";
}
selectedLabels?.forEach((label) => {
formData.append(labelKey, label);
});
}
};
export default {
load: async ({
page,
@ -326,8 +375,8 @@ export default {
onUploadProgress,
signal,
}: {
data: IEditPackageFormData;
orignalPackage: ISoftwarePackage;
data: IEditPackageFormData | ISoftwareDisplayNameFormData;
orignalPackage?: ISoftwarePackage;
softwareId: number;
teamId: number;
timeout?: number;
@ -335,42 +384,23 @@ export default {
signal?: AbortSignal;
}) => {
const { EDIT_SOFTWARE_PACKAGE } = endpoints;
const formData = new FormData();
formData.append("team_id", teamId.toString());
data.software && formData.append("software", data.software);
formData.append("self_service", data.selfService.toString());
formData.append("install_script", data.installScript);
formData.append("pre_install_query", data.preInstallQuery || "");
formData.append("post_install_script", data.postInstallScript || "");
formData.append("uninstall_script", data.uninstallScript || "");
if (data.categories) {
data.categories.forEach((category) => {
formData.append("categories", category);
});
}
// clear out labels if targetType is "All hosts"
if (data.targetType === "All hosts") {
if (orignalPackage.labels_include_any) {
formData.append("labels_include_any", "");
} else {
formData.append("labels_exclude_any", "");
if ("displayName" in data) {
// Handles Edit display name form only
handleDisplayNameForm(data, formData);
} else {
// TODO: Confirm if orignalPackage is required
if (!orignalPackage) {
throw new Error("originalPackage is required for EditPackageFormData");
}
}
// add custom labels if targetType is "Custom"
if (data.targetType === "Custom") {
const selectedLabels = listNamesFromSelectedLabels(data.labelTargets);
let labelKey = "";
if (data.customTarget === "labelsIncludeAny") {
labelKey = "labels_include_any";
} else {
labelKey = "labels_exclude_any";
}
selectedLabels?.forEach((label) => {
formData.append(labelKey, label);
});
// Handles primary Edit Package form
handleEditPackageForm(
data as IEditPackageFormData,
formData,
orignalPackage
);
}
return sendRequestWithProgress({