mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Zed + Opus 4.6; prompt: Convert the InputField JSX component to TypeScript and remove the ts-ignore directives that we no longer need after doing so. - [x] Changes file added - [x] Automated tests updated
771 lines
25 KiB
TypeScript
771 lines
25 KiB
TypeScript
import React, { useContext, useEffect, useState, useCallback } from "react";
|
|
import { useQuery, useQueryClient } from "react-query";
|
|
import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
|
|
import { noop } from "lodash";
|
|
|
|
import {
|
|
IAppStoreApp,
|
|
isIpadOrIphoneSoftwareSource,
|
|
ISoftwarePackage,
|
|
InstallerType,
|
|
} from "interfaces/software";
|
|
import { IInputFieldParseTarget } from "interfaces/form_field";
|
|
|
|
import { NotificationContext } from "context/notification";
|
|
import { AppContext } from "context/app";
|
|
import { INotification } from "interfaces/notification";
|
|
import { getErrorReason } from "interfaces/errors";
|
|
import softwareAPI from "services/entities/software";
|
|
|
|
import Modal from "components/Modal";
|
|
import ModalFooter from "components/ModalFooter";
|
|
import InputField from "components/forms/fields/InputField";
|
|
import FileUploader from "components/FileUploader";
|
|
import TabNav from "components/TabNav";
|
|
import TabText from "components/TabText";
|
|
import Card from "components/Card";
|
|
import Button from "components/buttons/Button";
|
|
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
|
|
import TableCount from "components/TableContainer/TableCount";
|
|
import Spinner from "components/Spinner";
|
|
|
|
import {
|
|
getDisplayedSoftwareName,
|
|
isSafeImagePreviewUrl,
|
|
} from "pages/SoftwarePage/helpers";
|
|
import SoftwareDetailsSummary from "pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary";
|
|
import { BasicSoftwareTable } from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal/CategoriesEndUserExperienceModal";
|
|
import SelfServicePreview from "pages/SoftwarePage/components/cards/SelfServicePreview";
|
|
|
|
import { TitleVersionsLastUpdatedInfo } from "../SoftwareSummaryCard/TitleVersionsTable/TitleVersionsTable";
|
|
|
|
const baseClass = "edit-icon-modal";
|
|
|
|
const ACCEPTED_EXTENSIONS = ".png";
|
|
const MIN_DIMENSION = 120;
|
|
const MAX_DIMENSION = 1024;
|
|
const MAX_FILE_SIZE = 100 * 1024; // 100kb in bytes
|
|
const UPLOAD_MESSAGE = `The icon must be a PNG file and square, with dimensions ranging from ${MIN_DIMENSION}x${MIN_DIMENSION} px to ${MAX_DIMENSION}x${MAX_DIMENSION} px.`;
|
|
const DEFAULT_ERROR_MESSAGE = "Couldn't edit. Please try again.";
|
|
|
|
const getFilenameFromContentDisposition = (header: string | null) => {
|
|
if (!header) return null;
|
|
// Try to match extended encoding (RFC 5987) first
|
|
const matchExtended = header.match(/filename\*\s*=\s*([^;]+)/);
|
|
if (matchExtended) {
|
|
// RFC 5987: filename*=UTF-8''something.png
|
|
const value = matchExtended[1].trim().replace(/^UTF-8''/, "");
|
|
return decodeURIComponent(value);
|
|
}
|
|
// Then standard quoted (or unquoted) filename param
|
|
const matchStandard = header.match(/filename\s*=\s*["']?([^"';]+)["']?/);
|
|
return matchStandard ? matchStandard[1] : null;
|
|
};
|
|
|
|
const makeFileDetails = (
|
|
file: File,
|
|
dimensions: number | null
|
|
): IFileDetails => ({
|
|
name: file.name,
|
|
description: `Software icon • ${dimensions || "?"}x${dimensions || "?"} px`,
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Icon preview state management
|
|
* - "apiCustom": Icon fetched directly via API, for custom uploads.
|
|
* - "customUpload": User-selected custom icon (not yet saved to backend).
|
|
* - "fallback": VPP app default or generic fallback icon.
|
|
*/
|
|
type IconStatus = "customUpload" | "apiCustom" | "fallback";
|
|
|
|
/**
|
|
* IconState keys:
|
|
* previewUrl: // A blob URL for the image that is currently previewed. Used as the <img src> for preview tabs.
|
|
* formData: // Holds the current icon File being edited/created that will be uploaded to the API.
|
|
* dimensions: // The pixel width/height (square) of the current icon. Used for validation and file details.
|
|
* fileDetails: // { name, description } for current icon file. Used for display in the FileUploader details.
|
|
* status: // What icon is being shown in the UI: "apiCustom": current API-fetched custom icon, "customUpload": icon chosen by FileUploader, "fallback": fallback/default icon if no custom icon
|
|
*/
|
|
interface IconState {
|
|
previewUrl: string | null;
|
|
formData: IFormData | null;
|
|
dimensions: number | null;
|
|
fileDetails: IFileDetails | null;
|
|
status: IconStatus;
|
|
}
|
|
|
|
// Encapsulate all icon-related UI and API state here
|
|
const defaultIconState: IconState = {
|
|
previewUrl: null,
|
|
formData: null,
|
|
dimensions: null,
|
|
fileDetails: null,
|
|
status: "apiCustom",
|
|
};
|
|
|
|
interface IEditIconModalProps {
|
|
softwareId: number;
|
|
teamIdForApi: number;
|
|
software: ISoftwarePackage | IAppStoreApp;
|
|
onExit: () => void;
|
|
refetchSoftwareTitle: () => void;
|
|
/** Timestamp used to force UI and cache updates after an icon change, since API will return the same URL. */
|
|
iconUploadedAt: string;
|
|
/** Updates the icon upload timestamp, triggering UI refetches to ensure a new custom icon appears called after successful icon update. */
|
|
setIconUploadedAt: (timestamp: string) => void;
|
|
installerType: InstallerType;
|
|
previewInfo: {
|
|
type?: string;
|
|
versions?: number;
|
|
selfServiceVersion?: string;
|
|
source?: string;
|
|
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;
|
|
};
|
|
}
|
|
|
|
const EditIconModal = ({
|
|
softwareId,
|
|
teamIdForApi,
|
|
software,
|
|
onExit,
|
|
refetchSoftwareTitle,
|
|
iconUploadedAt,
|
|
setIconUploadedAt,
|
|
installerType,
|
|
previewInfo,
|
|
}: IEditIconModalProps) => {
|
|
const { renderFlash, renderMultiFlash } = useContext(NotificationContext);
|
|
const { config } = useContext(AppContext);
|
|
const queryClient = useQueryClient();
|
|
|
|
const isSoftwarePackage = installerType === "package";
|
|
const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(
|
|
previewInfo?.source || ""
|
|
);
|
|
|
|
// Fetch current custom icon from API if applicable
|
|
const shouldFetchCustomIcon =
|
|
!!previewInfo.currentIconUrl &&
|
|
previewInfo.currentIconUrl.startsWith("/api/");
|
|
|
|
// 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 [isUpdatingSoftwareInfo, setIsUpdatingSoftwareInfo] = useState(false);
|
|
/** Shows loading spinner only if a custom icon and its information is loading from API */
|
|
const [isFirstLoadWithCustomIcon, setIsFirstLoadWithCustomIcon] = useState(
|
|
shouldFetchCustomIcon
|
|
);
|
|
|
|
const originalIsApiCustom =
|
|
!!previewInfo.currentIconUrl &&
|
|
previewInfo.currentIconUrl.startsWith("/api/");
|
|
const originalIsVpp =
|
|
!!previewInfo.currentIconUrl &&
|
|
!previewInfo.currentIconUrl.startsWith("/api/");
|
|
const isCustomUpload = iconState.status === "customUpload";
|
|
const isRemovedCustom =
|
|
originalIsApiCustom &&
|
|
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 = useCallback(
|
|
(file: File, width: number, previewUrl: string) =>
|
|
setIconState({
|
|
previewUrl,
|
|
formData: { icon: file, display_name: displayName },
|
|
dimensions: width,
|
|
fileDetails: makeFileDetails(file, width),
|
|
status: "apiCustom",
|
|
}),
|
|
[displayName]
|
|
);
|
|
|
|
// Sets state after a successful new custom file upload
|
|
const setCustomUpload = (file: File, width: number, previewUrl: string) =>
|
|
setIconState({
|
|
previewUrl,
|
|
formData: { icon: file },
|
|
dimensions: width,
|
|
fileDetails: makeFileDetails(file, width),
|
|
status: "customUpload",
|
|
});
|
|
|
|
// Reset state to fallback/default icon when a current or new custom icon is removed
|
|
const resetIconState = () => {
|
|
// Default to VPP icon if available, otherwise fall back to default icon
|
|
const defaultPreviewUrl =
|
|
previewInfo.currentIconUrl &&
|
|
!previewInfo.currentIconUrl.startsWith("/api/")
|
|
? previewInfo.currentIconUrl
|
|
: null;
|
|
|
|
setIconState({
|
|
previewUrl: defaultPreviewUrl,
|
|
formData: null,
|
|
dimensions: null,
|
|
fileDetails: null,
|
|
status: "fallback",
|
|
});
|
|
};
|
|
|
|
const { data: customIconData, isError: isCustomIconError } = useQuery(
|
|
["softwareIcon", softwareId, teamIdForApi, iconUploadedAt],
|
|
() => softwareAPI.getSoftwareIcon(softwareId, teamIdForApi),
|
|
{
|
|
enabled: shouldFetchCustomIcon,
|
|
retry: false,
|
|
select: (response) =>
|
|
response
|
|
? {
|
|
blob: response.data,
|
|
filename: getFilenameFromContentDisposition(
|
|
response.headers["content-disposition"]
|
|
),
|
|
url: URL.createObjectURL(response.data),
|
|
}
|
|
: "",
|
|
}
|
|
);
|
|
|
|
const onExitEditIconModal = () => {
|
|
resetIconState(); // Ensure cached state is cleared
|
|
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];
|
|
|
|
// Enforce filesize limit
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
renderFlash("error", "Couldn't edit. Icon must be 100KB or less.");
|
|
return;
|
|
}
|
|
|
|
// Enforce PNG MIME type, even though FileUploader also enforces by extension
|
|
if (file.type !== "image/png") {
|
|
renderFlash("error", "Couldn't edit. Must be a PNG file.");
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e: ProgressEvent<FileReader>) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const { width, height } = img;
|
|
if (
|
|
width !== height ||
|
|
width < MIN_DIMENSION ||
|
|
width > MAX_DIMENSION
|
|
) {
|
|
renderFlash(
|
|
"error",
|
|
`Couldn't edit. Icon must be square, between ${MIN_DIMENSION}x${MIN_DIMENSION}px and ${MAX_DIMENSION}x${MAX_DIMENSION}px.`
|
|
);
|
|
return;
|
|
}
|
|
const previewUrl = URL.createObjectURL(file);
|
|
setCustomUpload(file, width, previewUrl);
|
|
};
|
|
if (e.target && typeof e.target.result === "string") {
|
|
img.src = e.target.result;
|
|
} else {
|
|
renderFlash("error", "FileReader result was not a string.");
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
const onDeleteFile = () => resetIconState();
|
|
|
|
const onTabChange = (index: number) => setPreviewTabIndex(index);
|
|
|
|
// If there's currently a custom API icon and no new upload has happened yet,
|
|
// populate icon info from API-fetched custom icon
|
|
// useQuery does not handle dimension extraction, so this is required for updating
|
|
// state with image details after loading the icon blob in the browser
|
|
useEffect(() => {
|
|
// If the icon fetch failed, stop showing the spinner and fall back
|
|
if (isCustomIconError && isFirstLoadWithCustomIcon) {
|
|
setIsFirstLoadWithCustomIcon(false);
|
|
resetIconState();
|
|
return;
|
|
}
|
|
|
|
// Handle API custom icon blob conversion and initialization
|
|
if (
|
|
shouldFetchCustomIcon &&
|
|
iconState.status === "apiCustom" &&
|
|
customIconData &&
|
|
!iconState.previewUrl
|
|
) {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
fetch(customIconData.url)
|
|
.then((res) => {
|
|
const filename = customIconData.filename || "icon.png";
|
|
return res.blob().then((blob) => ({ blob, filename }));
|
|
})
|
|
.then(({ blob, filename }) => {
|
|
setCurrentApiCustomIcon(
|
|
new File([blob], filename, { type: "image/png" }),
|
|
img.width,
|
|
customIconData.url
|
|
);
|
|
setIsFirstLoadWithCustomIcon(false);
|
|
});
|
|
};
|
|
img.src = customIconData.url;
|
|
return; // Don't run fallback block below on initial load
|
|
}
|
|
|
|
// Or handle VPP fallback initialization (only when not using API custom icon)
|
|
if (originalIsVpp && iconState.status !== "customUpload") {
|
|
setIconState({
|
|
previewUrl: previewInfo.currentIconUrl,
|
|
formData: null,
|
|
dimensions: null,
|
|
fileDetails: null,
|
|
status: "fallback",
|
|
});
|
|
}
|
|
}, [
|
|
customIconData,
|
|
isCustomIconError,
|
|
isFirstLoadWithCustomIcon,
|
|
iconState.status,
|
|
shouldFetchCustomIcon,
|
|
iconState.previewUrl,
|
|
previewInfo.currentIconUrl,
|
|
originalIsVpp,
|
|
setCurrentApiCustomIcon,
|
|
]);
|
|
|
|
const fileDetails =
|
|
iconState.formData && iconState.formData.icon
|
|
? {
|
|
name: iconState.formData.icon.name,
|
|
description: `Software icon • ${iconState.dimensions || "?"}x${
|
|
iconState.dimensions || "?"
|
|
} px`,
|
|
}
|
|
: undefined;
|
|
|
|
const renderPreviewFleetCard = () => {
|
|
const {
|
|
name,
|
|
type,
|
|
versions,
|
|
source,
|
|
currentIconUrl,
|
|
countsUpdatedAt,
|
|
} = previewInfo;
|
|
return (
|
|
<Card
|
|
borderRadiusSize="medium"
|
|
color="grey"
|
|
className={`${baseClass}__preview-card`}
|
|
paddingSize="xlarge"
|
|
>
|
|
<Card
|
|
borderRadiusSize="xxlarge"
|
|
className={`${baseClass}__preview-card__fleet`}
|
|
>
|
|
<SoftwareDetailsSummary
|
|
displayName={displayName || previewInfo.titleName}
|
|
name={previewInfo.titleName}
|
|
type={type}
|
|
source={source}
|
|
iconUrl={
|
|
!currentIconUrl && software.icon_url ? software.icon_url : null
|
|
}
|
|
versions={versions}
|
|
iconPreviewUrl={iconState.previewUrl}
|
|
iconUploadedAt={iconUploadedAt}
|
|
/>
|
|
<div className={`${baseClass}__preview-results-count`}>
|
|
<TableCount name="versions" count={versions} />
|
|
{countsUpdatedAt && TitleVersionsLastUpdatedInfo(countsUpdatedAt)}
|
|
</div>
|
|
<div className={`data-table-block ${baseClass}__preview-table`}>
|
|
<div className="data-table data-table__wrapper">
|
|
<table className="data-table__table">
|
|
<thead>
|
|
<tr role="row">
|
|
<th
|
|
className="version__header"
|
|
colSpan={1}
|
|
role="columnheader"
|
|
>
|
|
<div className="column-header">Version</div>
|
|
</th>
|
|
<th
|
|
className="vulnerabilities__header"
|
|
colSpan={1}
|
|
role="columnheader"
|
|
>
|
|
<div className="column-header">Vulnerabilities</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr className="single-row" role="row">
|
|
<td className="version__cell" role="cell">
|
|
88.0.1
|
|
</td>
|
|
<td className="vulnerabilities__cell" role="cell">
|
|
<div
|
|
className="vulnerabilities-cell__vulnerability-text-with-tooltip"
|
|
data-tip="true"
|
|
data-for="86"
|
|
>
|
|
<span className="text-cell w250 italic-cell">
|
|
20 vulnerabilities
|
|
</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<div
|
|
className={`${baseClass}__mask-overlay ${baseClass}__mask-overlay--fleet`}
|
|
/>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Preview matches preview in Edit Software modal > Categories End User Experience modal
|
|
* Non-mobile preview:
|
|
* - uses HTML/CSS instead for maintainability as the self-service UI changes
|
|
* - dynamic name/icon
|
|
*
|
|
* Mobile preview modal:
|
|
* - uses a screenshot
|
|
* - dynamic name/icon/version
|
|
*/
|
|
|
|
const renderPreviewSelfServiceCard = () => (
|
|
<SelfServicePreview
|
|
isIosOrIpadosApp={isIosOrIpadosApp}
|
|
contactUrl={config?.org_info.contact_url || ""}
|
|
name={previewInfo.name}
|
|
displayName={displayName || previewInfo.titleName}
|
|
versionLabel={
|
|
"latest_version" in software
|
|
? software.latest_version
|
|
: software.version ||
|
|
previewInfo.selfServiceVersion ||
|
|
"Version (unknown)"
|
|
}
|
|
renderIcon={() =>
|
|
iconState.previewUrl && isSafeImagePreviewUrl(iconState.previewUrl) ? (
|
|
<img
|
|
src={iconState.previewUrl}
|
|
alt="Uploaded self-service icon"
|
|
style={{
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: "4px",
|
|
overflow: "hidden",
|
|
}}
|
|
/>
|
|
) : (
|
|
<SoftwareIcon
|
|
name={previewInfo.name}
|
|
source={previewInfo.source}
|
|
url={isSoftwarePackage ? undefined : software.icon_url} // fallback PNG icons only exist for VPP apps
|
|
uploadedAt={iconUploadedAt}
|
|
/>
|
|
)
|
|
}
|
|
renderTable={() => (
|
|
<BasicSoftwareTable
|
|
name={displayName || previewInfo.titleName}
|
|
displayName={displayName || previewInfo.titleName}
|
|
source={previewInfo.source}
|
|
iconUrl={isSoftwarePackage ? undefined : software.icon_url}
|
|
previewIcon={
|
|
iconState.previewUrl &&
|
|
isSafeImagePreviewUrl(iconState.previewUrl) ? (
|
|
<img
|
|
src={iconState.previewUrl}
|
|
alt="Uploaded self-service icon"
|
|
style={{
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: "4px",
|
|
overflow: "hidden",
|
|
}}
|
|
/>
|
|
) : (
|
|
<SoftwareIcon
|
|
name={previewInfo.titleName}
|
|
source={previewInfo.source}
|
|
url={isSoftwarePackage ? undefined : software.icon_url}
|
|
uploadedAt={iconUploadedAt}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
|
|
const defaultDisplayName = getDisplayedSoftwareName(previewInfo.titleName);
|
|
|
|
const renderForm = () => (
|
|
<>
|
|
<InputField
|
|
label="Display name"
|
|
onChange={onInputChange}
|
|
name="displayName"
|
|
value={displayName}
|
|
parseTarget
|
|
helpText={
|
|
<>
|
|
Optional. If left blank, Fleet will use{" "}
|
|
<strong>{defaultDisplayName}</strong>.
|
|
</>
|
|
}
|
|
autofocus
|
|
/>
|
|
<FileUploader
|
|
label="Icon"
|
|
canEdit
|
|
onDeleteFile={onDeleteFile}
|
|
graphicName="file-png"
|
|
accept={ACCEPTED_EXTENSIONS}
|
|
message={UPLOAD_MESSAGE}
|
|
onFileUpload={onFileSelect}
|
|
buttonMessage="Choose file"
|
|
buttonType="brand-inverse-icon"
|
|
className={`${baseClass}__file-uploader`}
|
|
fileDetails={fileDetails}
|
|
gitopsCompatible={false}
|
|
/>
|
|
<h2>Preview</h2>
|
|
<TabNav>
|
|
<Tabs selectedIndex={previewTabIndex} onSelect={onTabChange}>
|
|
<TabList>
|
|
<Tab>
|
|
<TabText>Fleet</TabText>
|
|
</Tab>
|
|
<Tab>
|
|
<TabText>Self-service</TabText>
|
|
</Tab>
|
|
</TabList>
|
|
<TabPanel>{renderPreviewFleetCard()}</TabPanel>
|
|
<TabPanel>{renderPreviewSelfServiceCard()}</TabPanel>
|
|
</Tabs>
|
|
</TabNav>
|
|
</>
|
|
);
|
|
|
|
const onClickSave = async () => {
|
|
setIsUpdatingSoftwareInfo(true);
|
|
const notifications: INotification[] = [];
|
|
let iconSucceeded = false;
|
|
let nameSucceeded = false;
|
|
let iconSuccessMessage: React.ReactElement | null = null;
|
|
let nameSuccessMessage: React.ReactElement | null = null;
|
|
|
|
try {
|
|
try {
|
|
if (
|
|
iconState.status === "fallback" &&
|
|
originalIsApiCustom &&
|
|
!iconState.formData?.icon
|
|
) {
|
|
await softwareAPI.deleteSoftwareIcon(softwareId, teamIdForApi);
|
|
iconSucceeded = true;
|
|
iconSuccessMessage = (
|
|
<>
|
|
Successfully removed icon from <b>{software?.name}</b>.
|
|
</>
|
|
);
|
|
} else if (iconState.status === "customUpload" && iconState.formData) {
|
|
await softwareAPI.editSoftwareIcon(
|
|
softwareId,
|
|
teamIdForApi,
|
|
iconState.formData
|
|
);
|
|
iconSucceeded = true;
|
|
iconSuccessMessage = (
|
|
<>
|
|
Successfully edited <b>{previewInfo.name}</b>.
|
|
</>
|
|
);
|
|
}
|
|
} catch (e) {
|
|
const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE;
|
|
notifications.push({
|
|
id: "icon-error",
|
|
alertType: "error",
|
|
isVisible: true,
|
|
message: errorMessage,
|
|
persistOnPageChange: false,
|
|
});
|
|
}
|
|
|
|
if (canSaveDisplayName) {
|
|
try {
|
|
const trimmedDisplayName = (displayName ?? "").trim();
|
|
await (installerType === "package"
|
|
? softwareAPI.editSoftwarePackage({
|
|
data: { displayName: trimmedDisplayName },
|
|
softwareId,
|
|
teamId: teamIdForApi,
|
|
})
|
|
: softwareAPI.editAppStoreApp(softwareId, teamIdForApi, {
|
|
displayName: trimmedDisplayName,
|
|
}));
|
|
nameSucceeded = true;
|
|
nameSuccessMessage =
|
|
trimmedDisplayName === "" ? (
|
|
<>
|
|
Successfully removed custom name for <b>{previewInfo.name}</b>.
|
|
</>
|
|
) : (
|
|
<>
|
|
Successfully renamed <b>{previewInfo.name}</b> to{" "}
|
|
<b>{trimmedDisplayName}</b>.
|
|
</>
|
|
);
|
|
} catch (e) {
|
|
const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE;
|
|
notifications.push({
|
|
id: "name-error",
|
|
alertType: "error",
|
|
isVisible: true,
|
|
message: errorMessage,
|
|
persistOnPageChange: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (notifications.length > 0) {
|
|
renderMultiFlash({ notifications });
|
|
} else if (iconSucceeded && nameSucceeded) {
|
|
// Both changed - show generic message to avoid double toast
|
|
renderFlash(
|
|
"success",
|
|
<>
|
|
Successfully edited{" "}
|
|
<b>{displayName === "" ? previewInfo.name : displayName}</b>.
|
|
</>
|
|
);
|
|
// Invalidate software titles list cache so the edit is reflected
|
|
// if the user navigates back before the stale time has passed.
|
|
queryClient.invalidateQueries({
|
|
queryKey: [{ scope: "software-titles" }],
|
|
});
|
|
refetchSoftwareTitle();
|
|
setIconUploadedAt(new Date().toISOString());
|
|
onExitEditIconModal();
|
|
} else if (iconSucceeded && iconSuccessMessage) {
|
|
renderFlash("success", iconSuccessMessage);
|
|
queryClient.invalidateQueries({
|
|
queryKey: [{ scope: "software-titles" }],
|
|
});
|
|
refetchSoftwareTitle();
|
|
setIconUploadedAt(new Date().toISOString());
|
|
onExitEditIconModal();
|
|
} else if (nameSucceeded && nameSuccessMessage) {
|
|
renderFlash("success", nameSuccessMessage);
|
|
queryClient.invalidateQueries({
|
|
queryKey: [{ scope: "software-titles" }],
|
|
});
|
|
refetchSoftwareTitle();
|
|
setIconUploadedAt(new Date().toISOString());
|
|
onExitEditIconModal();
|
|
}
|
|
} catch (e) {
|
|
const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE;
|
|
renderFlash("error", errorMessage);
|
|
} finally {
|
|
setIsUpdatingSoftwareInfo(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
className={baseClass}
|
|
title="Edit appearance"
|
|
onExit={onExitEditIconModal}
|
|
>
|
|
{isFirstLoadWithCustomIcon ? (
|
|
<Spinner includeContainer={false} />
|
|
) : (
|
|
renderForm()
|
|
)}
|
|
<ModalFooter
|
|
primaryButtons={
|
|
<Button
|
|
type="submit"
|
|
onClick={onClickSave}
|
|
isLoading={isUpdatingSoftwareInfo}
|
|
disabled={!canSaveForm || isUpdatingSoftwareInfo}
|
|
>
|
|
Save
|
|
</Button>
|
|
}
|
|
/>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default EditIconModal;
|