diff --git a/changes/35496-ability-to-edit-android-config-in-ui b/changes/35496-ability-to-edit-android-config-in-ui new file mode 100644 index 0000000000..f6fa7d14a6 --- /dev/null +++ b/changes/35496-ability-to-edit-android-config-in-ui @@ -0,0 +1 @@ +* Fleet UI: Add ability to edit Android software config \ No newline at end of file diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 81fb2d3f9e..a33f37453c 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -166,6 +166,32 @@ export const createMockAppStoreApp = (overrides?: Partial) => { 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 +) => { + return { ...DEFAULT_APP_STORE_APP_ANDROID_MOCK, ...overrides }; +}; + const DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK: ISoftwareTitleDetails = { id: 1, name: "test.app", diff --git a/frontend/hooks/useSoftwareInstallerMeta.ts b/frontend/hooks/useSoftwareInstallerMeta.ts new file mode 100644 index 0000000000..885279f32f --- /dev/null +++ b/frontend/hooks/useSoftwareInstallerMeta.ts @@ -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]); +}; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index bfa55a4601..540405f4b4 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -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 = [ diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx index 5d2a84feb9..812e3571a1 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx @@ -70,7 +70,7 @@ export interface IFormValidation { interface IFleetAppDetailsFormProps { labels: ILabelSummary[] | null; - categories?: SoftwareCategory[]; + categories?: SoftwareCategory[] | null; name: string; defaultInstallScript: string; defaultPostInstallScript: string; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx index 5901ee2ca7..a6edc66e61 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx @@ -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; } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tests.tsx new file mode 100644 index 0000000000..02ee08a769 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tests.tsx @@ -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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + const configInput = screen.getByRole("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(); + + const configInput = screen.getByRole("textbox", { + name: "", + }); + const saveButton = screen.getByRole("button", { name: "Save" }); + + await user.clear(configInput); + + await waitFor(() => { + expect(saveButton).not.toBeDisabled(); + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tsx new file mode 100644 index 0000000000..b32d46a152 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tsx @@ -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( + JSON.stringify(softwareInstaller.configuration, null, "\t") || "{}" + ); + const [formError, setFormError] = useState(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 + ) => { + 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", + <> + {softwareInstaller.name} 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 ( +
+ The Android app's configuration in JSON format.{" "} + +
+ ); + }; + + const renderForm = () => ( + <> + + + ); + + return ( + + <> + + {renderForm()} + + Save + + } + /> + + + ); +}; + +export default EditConfigurationModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/_styles.scss new file mode 100644 index 0000000000..fccb851a3f --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/_styles.scss @@ -0,0 +1,9 @@ +.edit-configuration-modal { + .modal__content { + @include vertical-modal-layout; + } + + .modal-footer__content-wrapper { + padding-top: $pad-large; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/helpers.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/helpers.tsx new file mode 100644 index 0000000000..a91288ba4d --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/helpers.tsx @@ -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't update configuration. Only + "managedConfiguration" and "workProfileWidgets" are + supported as top-level keys. + + ); + } + + return reason || DEFAULT_ERROR_MESSAGE; +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/index.ts new file mode 100644 index 0000000000..de3ffe5b8c --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/index.ts @@ -0,0 +1 @@ +export { default } from "./EditConfigurationModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tests.tsx index 57c15ba747..de7c4af210 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tests.tsx @@ -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, diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx index 04ce744a8a..f6aeb90701 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx @@ -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; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx index 4eba4eb4db..ee98069229 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx @@ -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; 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 {software.name}. + Successfully edited {softwareInstaller.name}. {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 ( @@ -347,7 +358,7 @@ const EditSoftwareModal = ({ {showConfirmSaveChangesModal && ( diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx index f53c74bd20..191987f58d 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx @@ -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, diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx index 4b72d1ae50..2fe16f3287 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx @@ -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; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx index b608fb08c3..238ba040a5 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx @@ -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} />
{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 = ({ />
)} - {showEditSoftwareModal && ( - setShowEditSoftwareModal(false)} - refetchSoftwareTitle={refetchSoftwareTitle} - installerType={installerType} - openViewYamlModal={onToggleViewYaml} - isIosOrIpadosApp={isIosOrIpadosApp} - /> - )} {showDeleteModal && ( 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 installer‑related UI. + if (!installerResult) { + // when no installer, no edit actions: + return ( + <> + + + {showVersionsTable && ( + + )} + + + ); + } - 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 ( <> {showVersionsTable && ( )} @@ -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 && ( + setShowEditSoftwareModal(false)} + refetchSoftwareTitle={refetchSoftwareTitle} + installerType={installerType} + openViewYamlModal={onToggleViewYaml} + isIosOrIpadosApp={isIosOrIpadosApp} + /> + )} + {showEditConfigurationModal && softwareInstaller && teamId && ( + setShowEditConfigurationModal(false)} + /> + )} ); }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index b5eb37995c..ba6a366103 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -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 ( ); }; @@ -184,18 +163,14 @@ const SoftwareTitleDetailsPage = ({ const renderSoftwareSummaryCard = (title: ISoftwareTitleDetails) => { return ( ); }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts index 4b1f8a028b..b8a105f75c 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts @@ -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", diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts index f31e4b4da2..7480bf9654 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts @@ -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, diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index fa19ea3d37..f82635969e 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -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) => { + 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 ( <>
@@ -111,9 +204,9 @@ const SoftwareDetailsSummary = ({ renderSoftwareIcon() )}
-
+

- {ROLLING_ARCH_LINUX_VERSIONS.includes(displayName) ? ( + {isRollingArch ? ( // wrap a tooltip around the "rolling" suffix <> {displayName.slice(0, -8)} @@ -123,22 +216,18 @@ const SoftwareDetailsSummary = ({ )}

- - {onClickEditIcon && ( - ( -
- -
- )} - /> + {canManageSoftware && ( +
+ +
)}
diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/_styles.scss b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/_styles.scss index 7959a23d81..2c41906c06 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/_styles.scss +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/_styles.scss @@ -17,10 +17,10 @@ } } - &__title-edit-icon { + &__title-actions { display: flex; align-items: center; - gap: $pad-medium; + justify-content: space-between; } h1 { diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx index a0375bcaf2..a269b05acf 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx @@ -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; diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index abd528e24a..e1fea00f39 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -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