Fleet UI: Ability to update android configuration + FE cleanups (#37065)

This commit is contained in:
RachelElysia
2025-12-15 09:26:33 -05:00
committed by GitHub
parent 3e7c4d1850
commit d8a3af4e88
26 changed files with 900 additions and 285 deletions
@@ -0,0 +1 @@
* Fleet UI: Add ability to edit Android software config
+26
View File
@@ -166,6 +166,32 @@ export const createMockAppStoreApp = (overrides?: Partial<IAppStoreApp>) => {
return { ...DEFAULT_APP_STORE_APP_MOCK, ...overrides };
};
const DEFAULT_APP_STORE_APP_ANDROID_MOCK: IAppStoreApp = {
name: "test app",
display_name: "Test App",
app_store_id: "com.test.app",
created_at: "2020-01-01T00:00:00.000Z",
platform: "android",
icon_url: "https://via.placeholder.com/512",
latest_version: "1.2.3",
self_service: true,
status: {
installed: 1,
pending: 2,
failed: 3,
},
categories: null,
labels_include_any: null,
labels_exclude_any: null,
configuration: '{ workProfileWidgets: "WORK_PROFILE_WIDGETS_ALLOWED" }',
};
export const createMockAppStoreAppAndroid = (
overrides?: Partial<IAppStoreApp>
) => {
return { ...DEFAULT_APP_STORE_APP_ANDROID_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK: ISoftwareTitleDetails = {
id: 1,
name: "test.app",
+125
View File
@@ -0,0 +1,125 @@
import { useContext, useMemo } from "react";
import { AppContext } from "context/app";
import { isAndroid } from "interfaces/platform";
import {
ISoftwareTitleDetails,
ISoftwarePackage,
IAppStoreApp,
isSoftwarePackage,
isIpadOrIphoneSoftwareSource,
InstallerType,
} from "interfaces/software";
import {
getInstallerCardInfo,
InstallerCardInfo,
} from "pages/SoftwarePage/SoftwareTitleDetailsPage/helpers";
export interface SoftwareInstallerMeta {
installerType: InstallerType;
isAndroidPlayStoreApp: boolean;
isFleetMaintainedApp: boolean;
isCustomPackage: boolean;
isIosOrIpadosApp: boolean;
sha256?: string;
androidPlayStoreId?: string;
automaticInstallPolicies:
| ISoftwarePackage["automatic_install_policies"]
| IAppStoreApp["automatic_install_policies"];
gitOpsModeEnabled: boolean;
repoURL?: string;
canManageSoftware: boolean;
/** Raw ISoftwarePackage | IAppStoreApp data */
softwareInstaller: ISoftwarePackage | IAppStoreApp;
}
export interface UseSoftwareInstallerResult {
cardInfo: InstallerCardInfo;
meta: SoftwareInstallerMeta;
}
/** This is used to extract software installer data
* (FMA, VPP, Google Playstore Apps, custom packages)
* from ISoftwareTitleDetails to be used in the UI */
export const useSoftwareInstaller = (
softwareTitle: ISoftwareTitleDetails
): UseSoftwareInstallerResult | undefined => {
const appContext = useContext(AppContext);
return useMemo(() => {
if (!softwareTitle.software_package && !softwareTitle.app_store_app) {
return undefined;
}
const cardInfo = getInstallerCardInfo(softwareTitle);
const { softwareInstaller, source } = cardInfo;
const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(source);
const installerType: InstallerType = isSoftwarePackage(softwareInstaller)
? "package"
: "app-store";
const isAndroidPlayStoreApp =
"platform" in softwareInstaller && isAndroid(softwareInstaller.platform);
const isFleetMaintainedApp =
"fleet_maintained_app_id" in softwareInstaller &&
!!softwareInstaller.fleet_maintained_app_id;
const isCustomPackage =
installerType === "package" && !isFleetMaintainedApp;
const sha256 =
("hash_sha256" in softwareInstaller && softwareInstaller.hash_sha256) ||
undefined;
const androidPlayStoreId =
isAndroidPlayStoreApp && "app_store_id" in softwareInstaller
? softwareInstaller?.app_store_id
: undefined;
const {
automatic_install_policies: automaticInstallPolicies,
} = softwareInstaller;
const {
isGlobalAdmin,
isGlobalMaintainer,
isTeamAdmin,
isTeamMaintainer,
config,
} = appContext;
const {
gitops_mode_enabled: configGitOpsModeEnabled,
repository_url: repoURL,
} = config?.gitops || {};
const gitOpsModeEnabled = !!configGitOpsModeEnabled;
const canManageSoftware = !!(
isGlobalAdmin ||
isGlobalMaintainer ||
isTeamAdmin ||
isTeamMaintainer
);
return {
cardInfo,
meta: {
installerType,
isAndroidPlayStoreApp,
isFleetMaintainedApp,
isCustomPackage,
isIosOrIpadosApp,
sha256,
androidPlayStoreId,
automaticInstallPolicies,
gitOpsModeEnabled,
repoURL,
canManageSoftware,
softwareInstaller,
},
};
}, [softwareTitle, appContext]);
};
+17 -10
View File
@@ -112,16 +112,11 @@ export interface ISoftwarePackage {
install_during_setup?: boolean;
labels_include_any: ILabelSoftwareTitle[] | null;
labels_exclude_any: ILabelSoftwareTitle[] | null;
categories?: SoftwareCategory[];
categories?: SoftwareCategory[] | null;
fleet_maintained_app_id?: number | null;
hash_sha256?: string | null;
}
export const isSoftwarePackage = (
data: ISoftwarePackage | IAppStoreApp
): data is ISoftwarePackage =>
(data as ISoftwarePackage).install_script !== undefined;
export interface IAppStoreApp {
name: string;
/** Not included in SoftwareTitle software.app_store_app response, hoisted up one level
@@ -146,9 +141,21 @@ export interface IAppStoreApp {
version?: string;
labels_include_any: ILabelSoftwareTitle[] | null;
labels_exclude_any: ILabelSoftwareTitle[] | null;
categories?: SoftwareCategory[];
categories?: SoftwareCategory[] | null;
configuration?: string;
}
/**
* package: includes FMA, custom packages, and are defined under software_package
* app-store: includes VPP, Google Play Store apps and are defined under app_store_app
*/
export type InstallerType = "package" | "app-store";
export const isSoftwarePackage = (
data: ISoftwarePackage | IAppStoreApp
): data is ISoftwarePackage =>
(data as ISoftwarePackage).install_script !== undefined;
export interface ISoftwareTitle {
id: number;
name: string;
@@ -514,7 +521,7 @@ export interface IHostSoftwarePackage {
version: string;
last_install: ISoftwareLastInstall | null;
last_uninstall: ISoftwareLastUninstall | null;
categories?: SoftwareCategory[];
categories?: SoftwareCategory[] | null;
automatic_install_policies?: ISoftwareInstallPolicy[] | null;
platform?: Platform;
}
@@ -526,7 +533,7 @@ export interface IHostAppStoreApp {
icon_url: string;
version: string;
last_install: IAppLastInstall | null;
categories?: SoftwareCategory[];
categories?: SoftwareCategory[] | null;
automatic_install_policies?: ISoftwareInstallPolicy[] | null;
}
@@ -795,7 +802,7 @@ export interface IFleetMaintainedAppDetails {
url: string;
slug: string;
software_title_id?: number; // null unless the team already has the software added (as a Fleet-maintained app, App Store (app), or custom package)
categories: SoftwareCategory[];
categories: SoftwareCategory[] | null;
}
export const ROLLING_ARCH_LINUX_NAMES = [
@@ -70,7 +70,7 @@ export interface IFormValidation {
interface IFleetAppDetailsFormProps {
labels: ILabelSummary[] | null;
categories?: SoftwareCategory[];
categories?: SoftwareCategory[] | null;
name: string;
defaultInstallScript: string;
defaultPostInstallScript: string;
@@ -1,5 +1,7 @@
import React from "react";
import { InstallerType } from "interfaces/software";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
@@ -8,7 +10,7 @@ const baseClass = "save-changes-modal";
export interface IConfirmSaveChangesModalProps {
onSaveChanges: () => void;
softwareInstallerName?: string;
installerType: "package" | "app-store";
installerType: InstallerType;
onClose: () => void;
}
@@ -0,0 +1,113 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import { createMockAppStoreAppAndroid } from "__mocks__/softwareMock";
import softwareAPI from "services/entities/software";
import EditConfigurationModal from "./EditConfigurationModal";
const softwareInstaller = createMockAppStoreAppAndroid();
const MOCK_PROPS = {
softwareId: 123,
teamId: 456,
softwareInstaller,
onExit: jest.fn(),
refetchSoftwareTitle: jest.fn(),
};
describe("EditConfigurationModal", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders modal title, configuration editor, help text, and save button", () => {
const render = createCustomRenderer({ withBackendMock: true });
render(<EditConfigurationModal {...MOCK_PROPS} />);
expect(screen.getByText("Edit configuration")).toBeInTheDocument();
// Editor label
expect(screen.getByText("Configuration")).toBeInTheDocument();
// Help text / learn more link
expect(
screen.getByText(/The Android app's configuration in JSON format/i)
).toBeInTheDocument();
expect(screen.getByText("Learn more")).toBeInTheDocument();
const save = screen.getByRole("button", { name: "Save" });
expect(save).toBeInTheDocument();
});
it("shows the installer details widget with software name", () => {
const render = createCustomRenderer({ withBackendMock: true });
render(<EditConfigurationModal {...MOCK_PROPS} />);
// InstallerDetailsWidget should show the software name somewhere
expect(screen.getAllByText(softwareInstaller.name).length).toBeGreaterThan(
0
);
// CustomDetails is "Android" in your props
expect(screen.getByText("Android")).toBeInTheDocument();
});
it("initializes the configuration editor with valid JSON (Save enabled)", async () => {
const render = createCustomRenderer({ withBackendMock: true });
render(<EditConfigurationModal {...MOCK_PROPS} />);
const saveButton = screen.getByRole("button", { name: "Save" });
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
});
it("calls onExit handler when modal close is triggered via Escape key", async () => {
const render = createCustomRenderer({ withBackendMock: true });
const { user } = render(<EditConfigurationModal {...MOCK_PROPS} />);
await user.keyboard("{Escape}");
expect(MOCK_PROPS.onExit).toHaveBeenCalled();
});
it("disables Save button when configuration JSON is invalid and shows the error", async () => {
const render = createCustomRenderer({ withBackendMock: true });
const { user } = render(<EditConfigurationModal {...MOCK_PROPS} />);
const configInput = screen.getByRole<HTMLTextAreaElement>("textbox", {
name: "",
});
const saveButton = screen.getByRole("button", { name: "Save" });
// Type some invalid JSON
await user.clear(configInput);
await user.type(configInput, "{{ invalid json");
// Error is rendered and Save disabled
await waitFor(() => {
expect(saveButton).toBeDisabled();
});
expect(
screen.getByText(/Expected property name or '}'/i)
).toBeInTheDocument();
});
it("enables Save button when an empty object when configuration field is cleared", async () => {
const render = createCustomRenderer({ withBackendMock: true });
const { user } = render(<EditConfigurationModal {...MOCK_PROPS} />);
const configInput = screen.getByRole<HTMLTextAreaElement>("textbox", {
name: "",
});
const saveButton = screen.getByRole("button", { name: "Save" });
await user.clear(configInput);
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
});
});
@@ -0,0 +1,174 @@
import React, { useContext, useState } from "react";
import { IAppStoreApp } from "interfaces/software";
import { NotificationContext } from "context/notification";
import softwareAPI from "services/entities/software";
import Modal from "components/Modal";
import ModalFooter from "components/ModalFooter";
import Editor from "components/Editor";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import InstallerDetailsWidget from "../SoftwareInstallerCard/InstallerDetailsWidget";
import { getErrorMessage } from "./helpers";
const baseClass = "edit-configuration-modal";
// Used to surface error.message in UI of unknown error type
type ErrorWithMessage = {
message: string;
[key: string]: unknown;
};
const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => {
return (error as ErrorWithMessage).message !== undefined;
};
export interface ISoftwareConfigurationFormData {
configuration: string;
}
interface EditConfigurationModal {
softwareId: number;
teamId: number;
softwareInstaller: IAppStoreApp;
refetchSoftwareTitle: () => void;
onExit: () => void;
}
const EditConfigurationModal = ({
softwareInstaller,
softwareId,
teamId,
refetchSoftwareTitle,
onExit,
}: EditConfigurationModal) => {
const { renderFlash, renderMultiFlash } = useContext(NotificationContext);
const [isUpdatingConfiguration, setIsUpdatingConfiguration] = useState(false);
const [canSaveForm, setCanSaveForm] = useState(true);
const [jsonFormData, setJsonFormData] = useState<string>(
JSON.stringify(softwareInstaller.configuration, null, "\t") || "{}"
);
const [formError, setFormError] = useState<string | null>(null);
const validateForm = (curFormData: string) => {
let error = null;
if (curFormData) {
try {
JSON.parse(curFormData);
} catch (e: unknown) {
if (isErrorWithMessage(e)) {
error = e.message.toString();
} else {
throw e;
}
}
}
return error;
};
// Edit package API call
const onEditConfiguration = async (
evt: React.MouseEvent<HTMLFormElement>
) => {
setIsUpdatingConfiguration(true);
evt.preventDefault();
// Format for API
const formDataToSubmit =
jsonFormData === ""
? { configuration: {} } // Send empty object if no keys are set
: {
configuration: (jsonFormData && JSON.parse(jsonFormData)) || null,
};
try {
await softwareAPI.editAppStoreApp(softwareId, teamId, formDataToSubmit);
renderFlash(
"success",
<>
<strong>{softwareInstaller.name}</strong> configuration updated.
</>
);
refetchSoftwareTitle();
onExit();
} catch (e) {
renderFlash(
"error",
getErrorMessage(e, softwareInstaller as IAppStoreApp)
);
}
setIsUpdatingConfiguration(false);
};
const onInputChange = (value: string) => {
setJsonFormData(value);
const error = validateForm(value);
setFormError(error);
setCanSaveForm(!error);
};
const renderHelpText = () => {
return (
<div className={`${baseClass}__help-text`}>
The Android app&apos;s configuration in JSON format.{" "}
<CustomLink
newTab
text="Learn more"
url={`${LEARN_MORE_ABOUT_BASE_LINK}/ui-gitops-mode`}
/>
</div>
);
};
const renderForm = () => (
<>
<Editor
mode="json"
value={jsonFormData as string}
helpText={renderHelpText()}
onChange={onInputChange}
error={formError}
label="Configuration"
/>
</>
);
return (
<Modal className={baseClass} title="Edit configuration" onExit={onExit}>
<>
<InstallerDetailsWidget
softwareName={softwareInstaller.name}
androidPlayStoreId={softwareInstaller.app_store_id}
customDetails="Android"
installerType="app-store"
isFma={false}
isScriptPackage={false}
/>
{renderForm()}
<ModalFooter
primaryButtons={
<Button
type="submit"
onClick={onEditConfiguration}
isLoading={isUpdatingConfiguration}
disabled={!canSaveForm || isUpdatingConfiguration}
>
Save
</Button>
}
/>
</>
</Modal>
);
};
export default EditConfigurationModal;
@@ -0,0 +1,9 @@
.edit-configuration-modal {
.modal__content {
@include vertical-modal-layout;
}
.modal-footer__content-wrapper {
padding-top: $pad-large;
}
}
@@ -0,0 +1,30 @@
import React from "react";
import { isAxiosError } from "axios";
import { getErrorReason } from "interfaces/errors";
import { IAppStoreApp, ISoftwarePackage } from "interfaces/software";
import { generateSecretErrMsg } from "pages/SoftwarePage/helpers";
const DEFAULT_ERROR_MESSAGE =
"Couldn't update configuration. Please try again.";
// eslint-disable-next-line import/prefer-default-export
export const getErrorMessage = (err: unknown, software: IAppStoreApp) => {
const reason = getErrorReason(err);
if (
reason.includes("managedConfiguration") ||
reason.includes("workProfileWidgets")
) {
return (
<>
Couldn&apos;t update configuration. Only
&quot;managedConfiguration&quot; and &quot;workProfileWidgets&quot; are
supported as top-level keys.
</>
);
}
return reason || DEFAULT_ERROR_MESSAGE;
};
@@ -0,0 +1 @@
export { default } from "./EditConfigurationModal";
@@ -5,6 +5,7 @@ import {
createMockSoftwarePackage,
createMockSoftwareTitle,
} from "__mocks__/softwareMock";
import { InstallerType } from "interfaces/software";
import softwareAPI from "services/entities/software";
import EditIconModal from "./EditIconModal";
@@ -18,7 +19,7 @@ const MOCK_PROPS = {
refetchSoftwareTitle: jest.fn(),
iconUploadedAt: "2025-09-03T12:00:00Z",
setIconUploadedAt: jest.fn(),
installerType: "package" as "package" | "vpp",
installerType: "package" as InstallerType,
previewInfo: {
type: "apps",
versions: software.versions?.length,
@@ -5,6 +5,7 @@ import {
IAppStoreApp,
isIpadOrIphoneSoftwareSource,
ISoftwarePackage,
InstallerType,
} from "interfaces/software";
import { IInputFieldParseTarget } from "interfaces/form_field";
@@ -124,7 +125,7 @@ interface IEditIconModalProps {
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: "package" | "vpp";
installerType: InstallerType;
previewInfo: {
type?: string;
versions?: number;
@@ -8,8 +8,10 @@ import {
IAppStoreApp,
ISoftwarePackage,
isSoftwarePackage,
InstallerType,
} from "interfaces/software";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import softwareAPI, {
MAX_FILE_SIZE_BYTES,
MAX_FILE_SIZE_MB,
@@ -46,12 +48,11 @@ export type IEditPackageFormData = Omit<IPackageFormData, "installType">;
interface IEditSoftwareModalProps {
softwareId: number;
teamId: number;
software: ISoftwarePackage | IAppStoreApp;
softwareInstaller: ISoftwarePackage | IAppStoreApp;
refetchSoftwareTitle: () => void;
onExit: () => void;
installerType: "package" | "app-store";
installerType: InstallerType;
router: InjectedRouter;
gitOpsModeEnabled?: boolean;
openViewYamlModal: () => void;
isIosOrIpadosApp?: boolean;
}
@@ -59,16 +60,18 @@ interface IEditSoftwareModalProps {
const EditSoftwareModal = ({
softwareId,
teamId,
software,
softwareInstaller,
onExit,
refetchSoftwareTitle,
installerType,
router,
gitOpsModeEnabled = false,
openViewYamlModal,
isIosOrIpadosApp = false,
}: IEditSoftwareModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const { config } = useContext(AppContext);
const gitOpsModeEnabled = config?.gitops.gitops_mode_enabled || false;
const [editSoftwareModalClasses, setEditSoftwareModalClasses] = useState(
baseClass
@@ -180,7 +183,7 @@ const EditSoftwareModal = ({
try {
await softwareAPI.editSoftwarePackage({
data: formData,
orignalPackage: software as ISoftwarePackage,
orignalPackage: softwareInstaller as ISoftwarePackage,
softwareId,
teamId,
onUploadProgress: (progressEvent) => {
@@ -192,8 +195,8 @@ const EditSoftwareModal = ({
});
if (
isSoftwarePackage(software) &&
software.title_id &&
isSoftwarePackage(softwareInstaller) &&
softwareInstaller.title_id &&
gitOpsModeEnabled
) {
// No longer flash message, we open YAML modal if editing with gitOpsModeEnabled
@@ -212,7 +215,10 @@ const EditSoftwareModal = ({
refetchSoftwareTitle();
onExit();
} catch (e) {
renderFlash("error", getErrorMessage(e, software as IAppStoreApp));
renderFlash(
"error",
getErrorMessage(e, softwareInstaller as IAppStoreApp)
);
}
setIsUpdatingSoftware(false);
};
@@ -222,7 +228,7 @@ const EditSoftwareModal = ({
};
const onClickSavePackage = (formData: IPackageFormData) => {
const softwarePackage = software as ISoftwarePackage;
const softwarePackage = softwareInstaller as ISoftwarePackage;
const currentData = {
software: null,
@@ -258,7 +264,7 @@ const EditSoftwareModal = ({
renderFlash(
"success",
<>
Successfully edited <b>{software.name}</b>.
Successfully edited <b>{softwareInstaller.name}</b>.
{formData.selfService
? " The end user can install from Fleet Desktop."
: ""}
@@ -267,18 +273,21 @@ const EditSoftwareModal = ({
onExit();
refetchSoftwareTitle();
} catch (e) {
renderFlash("error", getErrorMessage(e, software as IAppStoreApp));
renderFlash(
"error",
getErrorMessage(e, softwareInstaller as IAppStoreApp)
);
}
setIsUpdatingSoftware(false);
};
const onClickSaveVpp = async (formData: ISoftwareVppFormData) => {
const currentData = {
selfService: software.self_service || false,
automaticInstall: software.automatic_install || false,
targetType: getTargetType(software),
customTarget: getCustomTarget(software),
labelTargets: generateSelectedLabels(software),
selfService: softwareInstaller.self_service || false,
automaticInstall: softwareInstaller.automatic_install || false,
targetType: getTargetType(softwareInstaller),
customTarget: getCustomTarget(softwareInstaller),
labelTargets: generateSelectedLabels(softwareInstaller),
};
setPendingVppUpdates(formData);
@@ -302,7 +311,7 @@ const EditSoftwareModal = ({
const renderForm = () => {
if (installerType === "package") {
const softwarePackage = software as ISoftwarePackage;
const softwarePackage = softwareInstaller as ISoftwarePackage;
return (
<PackageForm
labels={labels || []}
@@ -311,7 +320,7 @@ const EditSoftwareModal = ({
onCancel={onExit}
onSubmit={onClickSavePackage}
onClickPreviewEndUserExperience={togglePreviewEndUserExperienceModal}
defaultSoftware={software}
defaultSoftware={softwareInstaller}
defaultInstallScript={softwarePackage.install_script}
defaultPreInstallQuery={softwarePackage.pre_install_query}
defaultPostInstallScript={softwarePackage.post_install_script}
@@ -325,7 +334,7 @@ const EditSoftwareModal = ({
return (
<SoftwareVppForm
labels={labels || []}
softwareVppForEdit={software as IAppStoreApp}
softwareVppForEdit={softwareInstaller as IAppStoreApp}
onSubmit={onClickSaveVpp}
onCancel={onExit}
isLoading={isUpdatingSoftware}
@@ -338,7 +347,9 @@ const EditSoftwareModal = ({
<>
<Modal
className={editSoftwareModalClasses}
title={isSoftwarePackage(software) ? "Edit package" : "Edit app"}
title={
isSoftwarePackage(softwareInstaller) ? "Edit package" : "Edit app"
}
onExit={onExit}
width="large"
>
@@ -347,7 +358,7 @@ const EditSoftwareModal = ({
{showConfirmSaveChangesModal && (
<ConfirmSaveChangesModal
onClose={toggleConfirmSaveChangesModal}
softwareInstallerName={software?.name}
softwareInstallerName={softwareInstaller?.name}
installerType={installerType}
onSaveChanges={onClickConfirmChanges}
/>
@@ -1,6 +1,7 @@
import React from "react";
import { screen } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import { InstallerType } from "interfaces/software";
import InstallerDetailsWidget from "./InstallerDetailsWidget";
@@ -19,7 +20,7 @@ const render = createCustomRenderer({ withBackendMock: true });
describe("InstallerDetailsWidget", () => {
const defaultProps = {
softwareName: "Test Software",
installerType: "package" as const,
installerType: "package" as InstallerType,
addedTimestamp: "2024-05-06T10:00:00Z",
version: "v1.2.3",
isFma: false,
@@ -9,6 +9,7 @@ import { internationalTimeFormat } from "utilities/helpers";
import { addedFromNow } from "utilities/date_format";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
import { InstallerType } from "interfaces/software";
import Graphic from "components/Graphic";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
@@ -60,13 +61,14 @@ const renderInstallerDisplayText = (
interface IInstallerDetailsWidgetProps {
className?: string;
softwareName: string;
installerType: "package" | "app-store";
installerType: InstallerType;
addedTimestamp?: string;
version?: string | null;
sha256?: string | null;
isFma: boolean;
isScriptPackage: boolean;
androidPlayStoreId?: string;
customDetails?: string;
}
const InstallerDetailsWidget = ({
@@ -79,6 +81,7 @@ const InstallerDetailsWidget = ({
isFma,
isScriptPackage,
androidPlayStoreId,
customDetails,
}: IInstallerDetailsWidgetProps) => {
const classNames = classnames(baseClass, className);
@@ -108,6 +111,10 @@ const InstallerDetailsWidget = ({
};
const renderDetails = () => {
if (customDetails) {
return <>{customDetails}</>;
}
const renderVersionInfo = () => {
if (isScriptPackage) {
return null;
@@ -5,14 +5,15 @@ import { InjectedRouter } from "react-router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { isAndroid } from "interfaces/platform";
import {
ISoftwareTitleDetails,
ISoftwarePackage,
IAppStoreApp,
isSoftwarePackage,
InstallerType,
} from "interfaces/software";
import softwareAPI from "services/entities/software";
import { useSoftwareInstaller } from "hooks/useSoftwareInstallerMeta";
import { getSelfServiceTooltip } from "pages/SoftwarePage/helpers";
import Card from "components/Card";
@@ -28,7 +29,6 @@ import CustomLink from "components/CustomLink";
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
import DeleteSoftwareModal from "../DeleteSoftwareModal";
import EditSoftwareModal from "../EditSoftwareModal";
import ViewYamlModal from "../ViewYamlModal";
import {
@@ -42,17 +42,10 @@ import InstallerPoliciesTable from "./InstallerPoliciesTable";
const baseClass = "software-installer-card";
interface IStatusDisplayOption {
displayName: string;
iconName: "success" | "pending-outline" | "error";
tooltip: React.ReactNode;
}
interface IActionsDropdownProps {
installerType: "package" | "app-store";
installerType: InstallerType;
onDownloadClick: () => void;
onDeleteClick: () => void;
onEditSoftwareClick: () => void;
gitOpsModeEnabled?: boolean;
repoURL?: string;
isFMA?: boolean;
@@ -63,7 +56,6 @@ export const SoftwareActionButtons = ({
installerType,
onDownloadClick,
onDeleteClick,
onEditSoftwareClick,
gitOpsModeEnabled,
repoURL,
isFMA,
@@ -96,11 +88,10 @@ export const SoftwareActionButtons = ({
</>
);
options = options.map((option) => {
// edit is disabled in gitOpsMode for VPP only
// delete is disabled in gitOpsMode for software types that can't be added in GitOps mode (FMA, VPP)
if (
(option.value === "edit" && installerType === "app-store") ||
(option.value === "delete" && (installerType === "app-store" || isFMA))
option.value === "delete" &&
(installerType === "app-store" || isFMA)
) {
return {
...option,
@@ -116,7 +107,6 @@ export const SoftwareActionButtons = ({
const actionHandlers = {
download: onDownloadClick,
delete: onDeleteClick,
edit: onEditSoftwareClick,
};
return (
@@ -154,106 +144,82 @@ export const SoftwareActionButtons = ({
};
interface ISoftwareInstallerCardProps {
softwareTitleName: string;
softwareDisplayName: string;
isScriptPackage?: boolean;
isIosOrIpadosApp?: boolean;
name: string;
version: string | null;
addedTimestamp: string;
status: {
installed: number;
pending: number;
failed: number;
};
isSelfService: boolean;
softwareId: number;
iconUrl?: string | null;
displayName?: string;
teamId: number;
teamIdForApi?: number;
softwareInstaller: ISoftwarePackage | IAppStoreApp;
onDelete: () => void;
refetchSoftwareTitle: () => void;
isLoading: boolean;
router: InjectedRouter;
gitOpsYamlParam?: boolean;
onToggleViewYaml: () => void;
showViewYamlModal: boolean;
softwareTitle: ISoftwareTitleDetails;
}
// NOTE: This component is dependent on having either a software package
// (ISoftwarePackage) or an app store app (IAppStoreApp). If we add more types
// of packages we should consider refactoring this to be more dynamic.
const SoftwareInstallerCard = ({
softwareTitleName,
softwareDisplayName,
isScriptPackage = false,
isIosOrIpadosApp = false,
name,
version,
addedTimestamp,
status,
isSelfService,
softwareInstaller,
softwareId,
iconUrl,
displayName,
teamId,
teamIdForApi,
onDelete,
refetchSoftwareTitle,
isLoading,
router,
gitOpsYamlParam = false,
onToggleViewYaml,
showViewYamlModal,
softwareTitle,
}: ISoftwareInstallerCardProps) => {
const installerType = isSoftwarePackage(softwareInstaller)
? "package"
: "app-store";
const isAndroidPlayStoreApp =
"platform" in softwareInstaller && isAndroid(softwareInstaller.platform);
const isFleetMaintainedApp =
"fleet_maintained_app_id" in softwareInstaller &&
!!softwareInstaller.fleet_maintained_app_id;
const isCustomPackage = installerType === "package" && !isFleetMaintainedApp;
const sha256 =
"hash_sha256" in softwareInstaller
? softwareInstaller.hash_sha256
: undefined;
const softwareInstallerMetaData = useSoftwareInstaller(softwareTitle);
if (!softwareInstallerMetaData) {
// This should never happen for SoftwareInstallerCard; fail fast in dev.
throw new Error(
"useSoftwareInstaller: called with a softwareTitle that has no installer"
);
}
const { cardInfo, meta: softwareInstallerMeta } = softwareInstallerMetaData;
const {
automatic_install_policies: automaticInstallPolicies,
} = softwareInstaller;
softwareTitleName,
softwareDisplayName,
softwareInstaller,
name,
version,
addedTimestamp,
status,
iconUrl,
displayName,
isSelfService,
isScriptPackage,
} = cardInfo;
const {
installerType,
isAndroidPlayStoreApp,
isFleetMaintainedApp,
isCustomPackage,
isIosOrIpadosApp,
sha256,
androidPlayStoreId,
automaticInstallPolicies,
gitOpsModeEnabled,
repoURL,
} = softwareInstallerMeta;
const {
isGlobalAdmin,
isGlobalMaintainer,
isTeamAdmin,
isTeamMaintainer,
config,
} = useContext(AppContext);
const { gitops_mode_enabled: gitOpsModeEnabled, repository_url: repoURL } =
config?.gitops || {};
const { renderFlash } = useContext(NotificationContext);
// gitOpsYamlParam URL Param controls whether the View Yaml modal is opened on page load
// as it automatically opens from adding flow of custom software in gitOps mode
const [showViewYamlModal, setShowViewYamlModal] = useState(gitOpsYamlParam);
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const onEditSoftwareClick = () => {
setShowEditSoftwareModal(true);
};
const onDeleteClick = () => {
setShowDeleteModal(true);
};
const onToggleViewYaml = () => {
setShowViewYamlModal(!showViewYamlModal);
};
const onDeleteSuccess = useCallback(() => {
setShowDeleteModal(false);
onDelete();
@@ -296,11 +262,7 @@ const SoftwareInstallerCard = ({
sha256={sha256}
isFma={isFleetMaintainedApp}
isScriptPackage={isScriptPackage}
androidPlayStoreId={
isAndroidPlayStoreApp
? softwareInstaller?.app_store_id
: undefined
}
androidPlayStoreId={androidPlayStoreId}
/>
<div className={`${baseClass}__tags-wrapper`}>
{Array.isArray(automaticInstallPolicies) &&
@@ -339,7 +301,6 @@ const SoftwareInstallerCard = ({
installerType={installerType}
onDownloadClick={onDownloadClick}
onDeleteClick={onDeleteClick}
onEditSoftwareClick={onEditSoftwareClick}
gitOpsModeEnabled={gitOpsModeEnabled}
repoURL={repoURL}
isFMA={isFleetMaintainedApp}
@@ -374,20 +335,6 @@ const SoftwareInstallerCard = ({
/>
</div>
)}
{showEditSoftwareModal && (
<EditSoftwareModal
router={router}
gitOpsModeEnabled={gitOpsModeEnabled}
softwareId={softwareId}
teamId={teamId}
software={softwareInstaller}
onExit={() => setShowEditSoftwareModal(false)}
refetchSoftwareTitle={refetchSoftwareTitle}
installerType={installerType}
openViewYamlModal={onToggleViewYaml}
isIosOrIpadosApp={isIosOrIpadosApp}
/>
)}
{showDeleteModal && (
<DeleteSoftwareModal
gitOpsModeEnabled={gitOpsModeEnabled}
@@ -14,11 +14,6 @@ const DOWNLOAD_OPTION: ISoftwareOption = {
iconName: "download",
};
const EDIT_OPTION: ISoftwareOption = {
value: "edit",
disabled: false,
iconName: "pencil",
};
const DELETE_OPTION: ISoftwareOption = {
value: "delete",
disabled: false,
@@ -27,14 +22,10 @@ const DELETE_OPTION: ISoftwareOption = {
export const SOFTWARE_PACKAGE_ACTION_OPTIONS = [
DOWNLOAD_OPTION,
EDIT_OPTION,
DELETE_OPTION,
] as const;
export const APP_STORE_APP_ACTION_OPTIONS = [
EDIT_OPTION,
DELETE_OPTION,
] as const;
export const APP_STORE_APP_ACTION_OPTIONS = [DELETE_OPTION] as const;
export const ANDROID_PLAY_STORE_APP_ACTION_OPTIONS = [DELETE_OPTION] as const;
@@ -1,105 +1,155 @@
/** software/titles/:id > First section */
import React, { useContext, useState } from "react";
import { AppContext } from "context/app";
import React, { useState } from "react";
import { InjectedRouter } from "react-router";
import { useSoftwareInstaller } from "hooks/useSoftwareInstallerMeta";
import {
formatSoftwareType,
isIpadOrIphoneSoftwareSource,
ISoftwareTitleDetails,
isSoftwarePackage,
ISoftwarePackage,
IAppStoreApp,
NO_VERSION_OR_HOST_DATA_SOURCES,
IAppStoreApp,
} from "interfaces/software";
import Card from "components/Card";
import SoftwareDetailsSummary from "pages/SoftwarePage/components/cards/SoftwareDetailsSummary";
import TitleVersionsTable from "./TitleVersionsTable";
import EditIconModal from "../EditIconModal";
import EditSoftwareModal from "../EditSoftwareModal";
import EditConfigurationModal from "../EditConfigurationModal";
interface ISoftwareSummaryCard {
title: ISoftwareTitleDetails;
softwareTitle: ISoftwareTitleDetails;
softwareId: number;
teamId?: number;
isAvailableForInstall?: boolean;
isLoading?: boolean;
router: InjectedRouter;
refetchSoftwareTitle: () => void;
softwareInstaller?: ISoftwarePackage | IAppStoreApp;
onToggleViewYaml: () => void;
}
const baseClass = "software-summary-card";
const SoftwareSummaryCard = ({
teamId,
softwareTitle,
softwareId,
teamId,
isAvailableForInstall,
title,
isLoading = false,
router,
softwareInstaller,
refetchSoftwareTitle,
onToggleViewYaml,
}: ISoftwareSummaryCard) => {
const { source } = title;
const {
isGlobalAdmin,
isGlobalMaintainer,
isTeamMaintainerOrTeamAdmin,
} = useContext(AppContext);
const installerResult = useSoftwareInstaller(softwareTitle);
const [iconUploadedAt, setIconUploadedAt] = useState("");
const [showEditIconModal, setShowEditIconModal] = useState(false);
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
const [showEditConfigurationModal, setShowEditConfigurationModal] = useState(
false
);
// Hide versions table for tgz_packages, sh_packages, & ps1_packages and when no hosts have the
// software installed
const showVersionsTable =
!!title.hosts_count && !NO_VERSION_OR_HOST_DATA_SOURCES.includes(source);
!!softwareTitle.hosts_count &&
!NO_VERSION_OR_HOST_DATA_SOURCES.includes(softwareTitle.source);
const hasEditPermissions =
isGlobalAdmin || isGlobalMaintainer || isTeamMaintainerOrTeamAdmin;
const canEditIcon =
softwareInstaller &&
typeof teamId === "number" &&
teamId >= 0 &&
hasEditPermissions;
// If there is no installer (no package/app), bail out of installerrelated UI.
if (!installerResult) {
// when no installer, no edit actions:
return (
<>
<Card borderRadiusSize="xxlarge" className={baseClass}>
<SoftwareDetailsSummary
displayName={softwareTitle.display_name || softwareTitle.name}
type={formatSoftwareType(softwareTitle)}
versions={softwareTitle.versions?.length ?? 0}
hostCount={softwareTitle.hosts_count}
countsUpdatedAt={softwareTitle.counts_updated_at}
queryParams={{ software_title_id: softwareId, team_id: teamId }}
name={softwareTitle.name}
source={softwareTitle.source}
iconUrl={softwareTitle.icon_url}
iconUploadedAt={iconUploadedAt}
/>
{showVersionsTable && (
<TitleVersionsTable
router={router}
data={softwareTitle.versions ?? []}
isLoading={isLoading}
teamIdForApi={teamId}
isIPadOSOrIOSApp={isIpadOrIphoneSoftwareSource(
softwareTitle.source
)}
isAvailableForInstall={isAvailableForInstall}
countsUpdatedAt={softwareTitle.counts_updated_at}
/>
)}
</Card>
</>
);
}
const [showEditIconModal, setShowEditIconModal] = useState(false);
const { meta } = installerResult;
const {
softwareInstaller,
installerType,
isIosOrIpadosApp,
isAndroidPlayStoreApp,
canManageSoftware,
} = meta;
const onClickEditIcon = () => {
setShowEditIconModal(!showEditIconModal);
};
const canEditAppearance = canManageSoftware;
const canEditSoftware = canManageSoftware;
const canEditConfiguration = canManageSoftware && isAndroidPlayStoreApp;
const onClickEditAppearance = () => setShowEditIconModal(true);
const onClickEditSoftware = () => setShowEditSoftwareModal(true);
const onClickEditConfiguration = () => setShowEditConfigurationModal(true);
return (
<>
<Card borderRadiusSize="xxlarge" className={baseClass}>
<SoftwareDetailsSummary
displayName={title.display_name || title.name}
type={formatSoftwareType(title)}
versions={title.versions?.length ?? 0}
hostCount={title.hosts_count}
countsUpdatedAt={title.counts_updated_at}
displayName={softwareTitle.display_name || softwareTitle.name}
type={formatSoftwareType(softwareTitle)}
versions={softwareTitle.versions?.length ?? 0}
hostCount={softwareTitle.hosts_count}
countsUpdatedAt={softwareTitle.counts_updated_at}
queryParams={{
software_title_id: softwareId,
team_id: teamId,
}}
name={title.name}
source={title.source}
iconUrl={title.icon_url}
name={softwareTitle.name}
source={softwareTitle.source}
iconUrl={softwareTitle.icon_url}
iconUploadedAt={iconUploadedAt}
onClickEditIcon={canEditIcon ? onClickEditIcon : undefined}
canManageSoftware={canManageSoftware}
onClickEditAppearance={
canEditAppearance ? onClickEditAppearance : undefined
}
onClickEditSoftware={
canEditSoftware ? onClickEditSoftware : undefined
}
onClickEditConfiguration={
canEditConfiguration ? onClickEditConfiguration : undefined
}
/>
{showVersionsTable && (
<TitleVersionsTable
router={router}
data={title.versions ?? []}
data={softwareTitle.versions ?? []}
isLoading={isLoading}
teamIdForApi={teamId}
isIPadOSOrIOSApp={isIpadOrIphoneSoftwareSource(title.source)}
isIPadOSOrIOSApp={isIosOrIpadosApp}
isAvailableForInstall={isAvailableForInstall}
countsUpdatedAt={title.counts_updated_at}
countsUpdatedAt={softwareTitle.counts_updated_at}
/>
)}
</Card>
@@ -115,21 +165,41 @@ const SoftwareSummaryCard = ({
refetchSoftwareTitle={refetchSoftwareTitle}
iconUploadedAt={iconUploadedAt}
setIconUploadedAt={setIconUploadedAt}
installerType={
isSoftwarePackage(softwareInstaller) ? "package" : "vpp"
}
installerType={installerType}
previewInfo={{
name: title.display_name || title.name,
titleName: title.name,
type: formatSoftwareType(title),
source: title.source,
currentIconUrl: title.icon_url,
versions: title.versions?.length ?? 0,
countsUpdatedAt: title.counts_updated_at,
name: softwareTitle.display_name || softwareTitle.name,
titleName: softwareTitle.name,
type: formatSoftwareType(softwareTitle),
source: softwareTitle.source,
currentIconUrl: softwareTitle.icon_url,
versions: softwareTitle.versions?.length ?? 0,
countsUpdatedAt: softwareTitle.counts_updated_at,
selfServiceVersion: softwareInstaller.version,
}}
/>
)}
{showEditSoftwareModal && softwareInstaller && teamId && (
<EditSoftwareModal
router={router}
softwareId={softwareId}
teamId={teamId}
softwareInstaller={softwareInstaller}
onExit={() => setShowEditSoftwareModal(false)}
refetchSoftwareTitle={refetchSoftwareTitle}
installerType={installerType}
openViewYamlModal={onToggleViewYaml}
isIosOrIpadosApp={isIosOrIpadosApp}
/>
)}
{showEditConfigurationModal && softwareInstaller && teamId && (
<EditConfigurationModal
softwareInstaller={softwareInstaller as IAppStoreApp}
softwareId={softwareId}
teamId={teamId}
refetchSoftwareTitle={refetchSoftwareTitle}
onExit={() => setShowEditConfigurationModal(false)}
/>
)}
</>
);
};
@@ -1,6 +1,6 @@
/** software/titles/:id */
import React, { useCallback, useContext } from "react";
import React, { useCallback, useContext, useState } from "react";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import { RouteComponentProps } from "react-router";
@@ -10,10 +10,7 @@ import paths from "router/paths";
import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
import { ignoreAxiosError } from "interfaces/errors";
import {
ISoftwareTitleDetails,
isIpadOrIphoneSoftwareSource,
} from "interfaces/software";
import { ISoftwareTitleDetails } from "interfaces/software";
import {
APP_CONTEXT_ALL_TEAMS_ID,
APP_CONTEXT_NO_TEAM_ID,
@@ -32,7 +29,6 @@ import TeamsHeader from "components/TeamsHeader";
import DetailsNoHosts from "../components/cards/DetailsNoHosts";
import SoftwareSummaryCard from "./SoftwareSummaryCard";
import SoftwareInstallerCard from "./SoftwareInstallerCard";
import { getInstallerCardInfo } from "./helpers";
const baseClass = "software-title-details-page";
@@ -77,6 +73,12 @@ const SoftwareTitleDetailsPage = ({
includeNoTeam: true,
});
// gitOpsYamlParam URL Param controls whether the View Yaml modal is opened on page load
// as it automatically opens from adding flow of custom software in gitOps mode
const [showViewYamlModal, setShowViewYamlModal] = useState(
autoOpenGitOpsYamlModal || false
);
const {
data: softwareTitle,
isLoading: isSoftwareTitleLoading,
@@ -105,6 +107,10 @@ const SoftwareTitleDetailsPage = ({
const isAvailableForInstall =
!!softwareTitle?.software_package || !!softwareTitle?.app_store_app;
const onToggleViewYaml = () => {
setShowViewYamlModal(!showViewYamlModal);
};
const onDeleteInstaller = useCallback(() => {
if (softwareTitle?.versions?.length) {
refetchSoftwareTitle();
@@ -140,43 +146,16 @@ const SoftwareTitleDetailsPage = ({
return null;
}
const {
softwareTitleName,
softwareDisplayName,
softwarePackage,
name,
version,
addedTimestamp,
status,
isSelfService,
isScriptPackage,
source,
} = getInstallerCardInfo(title);
const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(source);
return (
<SoftwareInstallerCard
softwareTitleName={softwareTitleName}
softwareDisplayName={softwareDisplayName}
isScriptPackage={isScriptPackage}
isIosOrIpadosApp={isIosOrIpadosApp}
softwareInstaller={softwarePackage}
name={name}
version={version}
iconUrl={title.icon_url}
displayName={title.display_name}
addedTimestamp={addedTimestamp}
status={status}
isSelfService={isSelfService}
softwareTitle={title}
softwareId={softwareId}
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
teamIdForApi={teamIdForApi}
onDelete={onDeleteInstaller}
refetchSoftwareTitle={refetchSoftwareTitle}
isLoading={isSoftwareTitleLoading}
router={router}
gitOpsYamlParam={autoOpenGitOpsYamlModal}
onToggleViewYaml={onToggleViewYaml}
showViewYamlModal={showViewYamlModal}
/>
);
};
@@ -184,18 +163,14 @@ const SoftwareTitleDetailsPage = ({
const renderSoftwareSummaryCard = (title: ISoftwareTitleDetails) => {
return (
<SoftwareSummaryCard
title={title}
softwareTitle={title}
softwareId={softwareId}
teamId={teamIdForApi}
isAvailableForInstall={isAvailableForInstall}
isLoading={isSoftwareTitleLoading}
router={router}
refetchSoftwareTitle={refetchSoftwareTitle}
softwareInstaller={
isAvailableForInstall
? getInstallerCardInfo(title).softwarePackage
: undefined
}
onToggleViewYaml={onToggleViewYaml}
/>
);
};
@@ -37,7 +37,9 @@ describe("SoftwareTitleDetailsPage helpers", () => {
};
const packageCardInfo = getInstallerCardInfo(softwareTitle);
expect(packageCardInfo).toEqual({
softwarePackage: softwareTitle.software_package,
softwareInstaller: softwareTitle.software_package,
displayName: undefined,
iconUrl: "https://example.com/icon.png",
name: "TestPackage.pkg", // packages should display the package name not the software title name
softwareDisplayName: "Test Software",
version: "1.0.0",
@@ -83,9 +85,11 @@ describe("SoftwareTitleDetailsPage helpers", () => {
};
const packageCardInfo = getInstallerCardInfo(softwareTitle);
expect(packageCardInfo).toEqual({
softwarePackage: softwareTitle.app_store_app,
softwareInstaller: softwareTitle.app_store_app,
name: "Test Software", // apps should display the software title name (backend should ensure the app name and software title name match)
softwareDisplayName: "Test App",
displayName: "Test App",
iconUrl: "https://example.com/icon.png",
version: "1.0.1",
addedTimestamp: "2020-01-01T00:00:00.000Z",
softwareTitleName: "Test Software",
@@ -4,20 +4,32 @@ import {
isSoftwarePackage,
aggregateInstallStatusCounts,
SCRIPT_PACKAGE_SOURCES,
ISoftwarePackage,
} from "interfaces/software";
/**
* Generates the data needed to render the installer card. It differentiates between
* software packages and app store apps and returns the appropriate data.
*
* FIXME: This function ought to be refactored or renamed to better reflect its purpose.
* "PackageCard" is a bit ambiguous in this context (it refers to the card that displays
* package or app information, as applicable).
*/
export interface InstallerCardInfo {
softwareTitleName: string;
softwareDisplayName: string;
softwareInstaller: ISoftwarePackage | IAppStoreApp;
name: string;
version: string | null;
source: ISoftwareTitleDetails["source"];
addedTimestamp: string;
status: {
installed: number;
pending: number;
failed: number;
};
isSelfService: boolean;
isScriptPackage: boolean;
iconUrl?: string | null;
displayName?: string;
}
// eslint-disable-next-line import/prefer-default-export
export const getInstallerCardInfo = (softwareTitle: ISoftwareTitleDetails) => {
// we know at this point that softwareTitle.software_package or
// softwareTitle.app_store_app is not null so we will do a type assertion.
export const getInstallerCardInfo = (
softwareTitle: ISoftwareTitleDetails
): InstallerCardInfo => {
const installerData = softwareTitle.software_package
? softwareTitle.software_package
: (softwareTitle.app_store_app as IAppStoreApp);
@@ -27,12 +39,14 @@ export const getInstallerCardInfo = (softwareTitle: ISoftwareTitleDetails) => {
return {
softwareTitleName: softwareTitle.name,
softwareDisplayName: softwareTitle.display_name || softwareTitle.name,
softwarePackage: installerData,
softwareInstaller: installerData,
name: (isPackage && installerData.name) || softwareTitle.name,
version:
(isPackage ? installerData.version : installerData.latest_version) ||
null,
source: softwareTitle.source,
iconUrl: softwareTitle.icon_url,
displayName: softwareTitle.display_name,
addedTimestamp: isPackage
? installerData.uploaded_at
: installerData.created_at,
@@ -4,29 +4,83 @@ software/versions/:id > Top section
software/os/:id > Top section
*/
import React from "react";
import React, { useContext } from "react";
import { SingleValue } from "react-select-5";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import { TooltipContent } from "interfaces/dropdownOption";
import { getPathWithQueryParams, QueryParams } from "utilities/url";
import { getGitOpsModeTipContent } from "utilities/helpers";
import paths from "router/paths";
import {
NO_VERSION_OR_HOST_DATA_SOURCES,
ROLLING_ARCH_LINUX_VERSIONS,
} from "interfaces/software";
import { AppContext } from "context/app";
import DataSet from "components/DataSet";
import LastUpdatedHostCount from "components/LastUpdatedHostCount";
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
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";
import { isSafeImagePreviewUrl } from "pages/SoftwarePage/helpers";
import TooltipWrapperArchLinuxRolling from "components/TooltipWrapperArchLinuxRolling";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import SoftwareIcon from "../../icons/SoftwareIcon";
import OSIcon from "../../icons/OSIcon";
const buildActionOptions = (
gitOpsModeEnabled: boolean | undefined,
repoURL: string | undefined,
source: string | undefined,
androidSoftwareAvailableForInstall: boolean
): CustomOptionType[] => {
let disableEditAppearanceTooltipContent: TooltipContent | undefined;
let disableEditSoftwareTooltipContent: TooltipContent | undefined;
let disabledEditConfigurationTooltipContent: TooltipContent | undefined;
if (gitOpsModeEnabled) {
const gitOpsModeTooltipContent =
repoURL && getGitOpsModeTipContent(repoURL);
disableEditAppearanceTooltipContent = gitOpsModeTooltipContent;
disabledEditConfigurationTooltipContent = gitOpsModeTooltipContent;
if (source === "vpp_apps") {
disableEditSoftwareTooltipContent = gitOpsModeTooltipContent;
}
}
const options: CustomOptionType[] = [
{
label: "Edit appearance",
value: "edit_appearance",
isDisabled: !!disableEditAppearanceTooltipContent,
tooltipContent: disableEditAppearanceTooltipContent,
},
{
label: "Edit software",
value: "edit_software",
isDisabled: !!disableEditSoftwareTooltipContent,
tooltipContent: disableEditSoftwareTooltipContent,
},
];
if (androidSoftwareAvailableForInstall) {
options.push({
label: "Edit configuration",
value: "edit_configuration",
isDisabled: !!disabledEditConfigurationTooltipContent,
tooltipContent: disabledEditConfigurationTooltipContent,
});
}
return options;
};
const baseClass = "software-details-summary";
interface ISoftwareDetailsSummaryProps {
@@ -46,10 +100,18 @@ interface ISoftwareDetailsSummaryProps {
iconUrl?: string | null;
/** Displays OS icon instead of Software icon */
isOperatingSystem?: boolean;
/** Shows Actions dropdown allowing user to edit software */
canManageSoftware?: boolean;
/** Displays an edit CTA to edit the software's icon and display name
* Should only be defined for team view of an installable software */
onClickEditAppearance?: () => void;
/** Displays an edit CTA to edit the software installer
* Should only be defined for team view of an installable software */
onClickEditSoftware?: () => void;
/** undefined unless previewing icon, in which case is string or null */
/** Displays an edit CTA to edit the software's icon
* Should only be defined for team view of an installable software */
onClickEditIcon?: () => void;
/** undefined unless previewing icon, in which case is string or null */
onClickEditConfiguration?: () => void;
iconPreviewUrl?: string | null;
/** timestamp of when icon was last uploaded, used to force refresh of cached icon */
iconUploadedAt?: string;
@@ -66,12 +128,36 @@ const SoftwareDetailsSummary = ({
versions,
iconUrl,
isOperatingSystem,
onClickEditIcon,
canManageSoftware = false,
onClickEditAppearance,
onClickEditSoftware,
onClickEditConfiguration,
iconPreviewUrl,
iconUploadedAt,
}: ISoftwareDetailsSummaryProps) => {
const hostCountPath = getPathWithQueryParams(paths.MANAGE_HOSTS, queryParams);
const { config } = useContext(AppContext);
const gitOpsModeEnabled = config?.gitops.gitops_mode_enabled;
const repoURL = config?.gitops.repository_url;
const isRollingArch = ROLLING_ARCH_LINUX_VERSIONS.includes(displayName);
const onSelectSoftwareAction = (option: SingleValue<CustomOptionType>) => {
switch (option?.value) {
case "edit_appearance":
onClickEditAppearance && onClickEditAppearance();
break;
case "edit_software":
onClickEditSoftware && onClickEditSoftware();
break;
case "edit_configuration":
onClickEditConfiguration && onClickEditConfiguration();
break;
default:
}
};
// Remove host count for tgz_packages, sh_packages, and ps1_packages only
// or if viewing details summary from edit icon preview modal
const showHostCount =
@@ -102,6 +188,13 @@ const SoftwareDetailsSummary = ({
);
};
const actionOptions = buildActionOptions(
gitOpsModeEnabled,
repoURL,
source,
!!onClickEditConfiguration
);
return (
<>
<div className={baseClass}>
@@ -111,9 +204,9 @@ const SoftwareDetailsSummary = ({
renderSoftwareIcon()
)}
<dl className={`${baseClass}__info`}>
<div className={`${baseClass}__title-edit-icon`}>
<div className={`${baseClass}__title-actions`}>
<h1>
{ROLLING_ARCH_LINUX_VERSIONS.includes(displayName) ? (
{isRollingArch ? (
// wrap a tooltip around the "rolling" suffix
<>
{displayName.slice(0, -8)}
@@ -123,22 +216,18 @@ const SoftwareDetailsSummary = ({
<TooltipTruncatedText value={displayName} />
)}
</h1>
{onClickEditIcon && (
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<div className={`${baseClass}__edit-icon`}>
<Button
onClick={onClickEditIcon}
className={`${baseClass}__edit-icon-btn`}
disabled={disableChildren}
variant="icon"
>
<Icon name="pencil" />
</Button>
</div>
)}
/>
{canManageSoftware && (
<div className={`${baseClass}__actions-wrapper`}>
<DropdownWrapper
className={`${baseClass}__actions-dropdown`}
name="software-actions"
onChange={onSelectSoftwareAction}
placeholder="Actions"
options={actionOptions}
variant="button"
nowrapMenu
/>
</div>
)}
</div>
<dl className={`${baseClass}__description-list`}>
@@ -17,10 +17,10 @@
}
}
&__title-edit-icon {
&__title-actions {
display: flex;
align-items: center;
gap: $pad-medium;
justify-content: space-between;
}
h1 {
@@ -84,7 +84,7 @@ interface IPackageFormProps {
defaultPostInstallScript?: string;
defaultUninstallScript?: string;
defaultSelfService?: boolean;
defaultCategories?: SoftwareCategory[];
defaultCategories?: SoftwareCategory[] | null;
className?: string;
/** Indicates that this PackageForm deals with an entity that can be managed by GitOps, and so should be disabled when gitops mode is enabled */
gitopsCompatible?: boolean;
+16
View File
@@ -29,6 +29,7 @@ import { ISoftwareDisplayNameFormData } from "pages/SoftwarePage/SoftwareTitleDe
import { IAddFleetMaintainedData } from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage";
import { listNamesFromSelectedLabels } from "components/TargetLabelSelector/TargetLabelSelector";
import { ISoftwareAndroidFormData } from "pages/SoftwarePage/components/forms/SoftwareAndroidForm/SoftwareAndroidForm";
import { ISoftwareConfigurationFormData } from "pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal";
export interface ISoftwareApiParams {
page?: number;
@@ -179,6 +180,7 @@ export interface IEditAppStoreAppPostBody {
labels_exclude_any?: string[];
categories?: SoftwareCategory[];
display_name?: string;
configuration?: string;
}
const ORDER_KEY = "name";
@@ -303,6 +305,13 @@ const handleDisplayNameAppStoreAppForm = (
body.display_name = formData.displayName || "";
};
const handleConfigurationAppStoreAppForm = (
formData: ISoftwareConfigurationFormData,
body: IEditAppStoreAppPostBody
) => {
body.configuration = formData.configuration || "{}";
};
const handleEditAppStoreAppForm = (
formData: ISoftwareVppFormData,
body: IEditAppStoreAppPostBody
@@ -557,6 +566,7 @@ export default {
| ISoftwareVppFormData
| ISoftwareAndroidFormData
| ISoftwareDisplayNameFormData
| ISoftwareConfigurationFormData
) => {
const { EDIT_SOFTWARE_APP_STORE_APP } = endpoints;
@@ -568,6 +578,12 @@ export default {
formData as ISoftwareDisplayNameFormData,
body
);
} else if ("configuration" in formData) {
// Handles Edit configuration form only
handleConfigurationAppStoreAppForm(
formData as ISoftwareConfigurationFormData,
body
);
} else {
// Handles primary Edit AppStoreApp form
// 4.77 Currently, only VPP apps can be edited, not Google Play apps