diff --git a/changes/20404-edit-software b/changes/20404-edit-software new file mode 100644 index 000000000000..ec65b392b41a --- /dev/null +++ b/changes/20404-edit-software @@ -0,0 +1 @@ +* Software installer packages, self-service flag, scripts, pre-install query, and self-service availability can now be edited in-place rather than needing to be deleted and re-added. diff --git a/docs/Contributing/Audit-logs.md b/docs/Contributing/Audit-logs.md index c4baf17b74dd..7ed5be322f73 100644 --- a/docs/Contributing/Audit-logs.md +++ b/docs/Contributing/Audit-logs.md @@ -1216,6 +1216,29 @@ This activity contains the following fields: } ``` +## edited_software + +Generated when a software installer is updated in Fleet. + +This activity contains the following fields: +- "software_title": Name of the software. +- "software_package": Filename of the installer. `null` if the installer package was not modified. +- "team_name": Name of the team on which this software was updated. `null` if it was updated on no team. +- "team_id": The ID of the team on which this software was updated. `null` if it was updated on no team. +- "self_service": Whether the software is available for installation by the end user. + +#### Example + +```json +{ + "software_title": "Falcon.app", + "software_package": "FalconSensor-6.44.pkg", + "team_name": "Workstations", + "team_id": 123, + "self_service": true +} +``` + ## deleted_software Generated when a software installer is deleted from Fleet. diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index ec1172310f41..4745e85416b9 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -117,6 +117,228 @@ func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) { payload.UninstallScript = packageIDRegex.ReplaceAllString(payload.UninstallScript, fmt.Sprintf("%s${suffix}", packageID)) } +func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) { + if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: payload.TeamID}, fleet.ActionWrite); err != nil { + return nil, err + } + + vc, ok := viewer.FromContext(ctx) + if !ok { + return nil, fleet.ErrNoContext + } + payload.UserID = vc.UserID() + + if payload.TeamID == nil { + return nil, &fleet.BadRequestError{Message: "team_id is required; enter 0 for no team"} + } + + var teamName *string + if *payload.TeamID != 0 { + t, err := svc.ds.Team(ctx, *payload.TeamID) + if err != nil { + return nil, err + } + teamName = &t.Name + } + + // get software by ID, fail if it does not exist or does not have an existing installer + software, err := svc.ds.SoftwareTitleByID(ctx, payload.TitleID, payload.TeamID, fleet.TeamFilter{ + User: vc.User, + IncludeObserver: true, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting software title by id") + } + + // TODO when we start supporting multiple installers per title X team, need to rework how we determine installer to edit + if software.SoftwareInstallersCount != 1 { + return nil, &fleet.BadRequestError{ + Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.", + } + } + + existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting existing installer") + } + + if payload.SelfService == nil && payload.InstallerFile == nil && payload.PreInstallQuery == nil && + payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil { + return existingInstaller, nil // no payload, noop + } + + payload.InstallerID = existingInstaller.InstallerID + dirty := make(map[string]bool) + + if payload.SelfService != nil && *payload.SelfService != existingInstaller.SelfService { + dirty["SelfService"] = true + } + + activity := fleet.ActivityTypeEditedSoftware{ + SoftwareTitle: existingInstaller.SoftwareTitle, + TeamName: teamName, + TeamID: payload.TeamID, + SelfService: existingInstaller.SelfService, + } + + var payloadForNewInstallerFile *fleet.UploadSoftwareInstallerPayload + if payload.InstallerFile != nil { + payloadForNewInstallerFile = &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: payload.InstallerFile, + Filename: payload.Filename, + } + + newInstallerExtension, err := svc.addMetadataToSoftwarePayload(ctx, payloadForNewInstallerFile) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "extracting updated installer metadata") + } + + if newInstallerExtension != existingInstaller.Extension { + return nil, &fleet.BadRequestError{ + Message: "The selected package is for a different file type.", + InternalErr: ctxerr.Wrap(ctx, err, "installer extension mismatch"), + } + } + + if payloadForNewInstallerFile.Title != software.Name { + return nil, &fleet.BadRequestError{ + Message: "The selected package is for different software.", + InternalErr: ctxerr.Wrap(ctx, err, "installer software title mismatch"), + } + } + + if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID { + activity.SoftwarePackage = &payload.Filename + payload.StorageID = payloadForNewInstallerFile.StorageID + payload.Filename = payloadForNewInstallerFile.Filename + payload.Version = payloadForNewInstallerFile.Version + payload.PackageIDs = payloadForNewInstallerFile.PackageIDs + + dirty["Package"] = true + } else { // noop if uploaded installer is identical to previous installer + payloadForNewInstallerFile = nil + payload.InstallerFile = nil + } + } + + if payload.InstallerFile == nil { // fill in existing existingInstaller data to payload + payload.StorageID = existingInstaller.StorageID + payload.Filename = existingInstaller.Name + payload.Version = existingInstaller.Version + payload.PackageIDs = existingInstaller.PackageIDs() + } + + // default pre-install query is blank, so blanking out the query doesn't have a semantic meaning we have to take care of + if payload.PreInstallQuery != nil && *payload.PreInstallQuery != existingInstaller.PreInstallQuery { + dirty["PreInstallQuery"] = true + } + + if payload.InstallScript != nil { + installScript := file.Dos2UnixNewlines(*payload.InstallScript) + if installScript == "" { + installScript = file.GetInstallScript(existingInstaller.Extension) + } + + if installScript != existingInstaller.InstallScript { + dirty["InstallScript"] = true + payload.InstallScript = &installScript + } + } + + if payload.PostInstallScript != nil { + postInstallScript := file.Dos2UnixNewlines(*payload.PostInstallScript) + if postInstallScript != existingInstaller.PostInstallScript { + dirty["PostInstallScript"] = true + payload.PostInstallScript = &postInstallScript + } + } + + if payload.UninstallScript != nil { + uninstallScript := file.Dos2UnixNewlines(*payload.UninstallScript) + if uninstallScript == "" { // extension can't change on an edit so we can generate off of the existing file + uninstallScript = file.GetUninstallScript(existingInstaller.Extension) + } + + payloadForUninstallScript := &fleet.UploadSoftwareInstallerPayload{ + Extension: existingInstaller.Extension, + UninstallScript: uninstallScript, + PackageIDs: existingInstaller.PackageIDs(), + } + if payloadForNewInstallerFile != nil { + payloadForUninstallScript.PackageIDs = payloadForNewInstallerFile.PackageIDs + } + + preProcessUninstallScript(payloadForUninstallScript) + if payloadForUninstallScript.UninstallScript != existingInstaller.UninstallScript { + uninstallScript = payloadForUninstallScript.UninstallScript + dirty["UninstallScript"] = true + payload.UninstallScript = &uninstallScript + } + } + + // persist changes starting here, now that we've done all the validation/diffing we can + if len(dirty) > 0 { + if len(dirty) == 1 && dirty["SelfService"] == true { // only self-service changed; use lighter update function + if err := svc.ds.UpdateInstallerSelfServiceFlag(ctx, *payload.SelfService, existingInstaller.InstallerID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "updating installer self service flag") + } + } else { + if payloadForNewInstallerFile != nil { + if err := svc.storeSoftware(ctx, payloadForNewInstallerFile); err != nil { + return nil, ctxerr.Wrap(ctx, err, "storing software installer") + } + } + + // fill in values from existing installer if they weren't supplied + if payload.InstallScript == nil { + payload.InstallScript = &existingInstaller.InstallScript + } + if payload.UninstallScript == nil { + payload.UninstallScript = &existingInstaller.UninstallScript + } + if payload.PostInstallScript == nil && dirty["PostInstallScript"] == false { + payload.PostInstallScript = &existingInstaller.PostInstallScript + } + if payload.PreInstallQuery == nil { + payload.PreInstallQuery = &existingInstaller.PreInstallQuery + } + if payload.SelfService == nil { + payload.SelfService = &existingInstaller.SelfService + } + + if err := svc.ds.SaveInstallerUpdates(ctx, payload); err != nil { + return nil, ctxerr.Wrap(ctx, err, "saving installer updates") + } + + // if we're updating anything other than self-service, we cancel pending installs/uninstalls, + // and if we're updating the package we reset counts. This is run in its own transaction internally + // for consistency, but independent of the installer update query as the main update should stick + // even if side effects fail. + if err := svc.ds.ProcessInstallerUpdateSideEffects(ctx, existingInstaller.InstallerID, true, dirty["Package"] == true); err != nil { + return nil, err + } + } + + if err := svc.NewActivity(ctx, vc.User, activity); err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating activity for edited software") + } + } + + // re-pull installer from database to ensure any side effects are accounted for; may be able to optimize this out later + updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata") + } + + statuses, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, updatedInstaller.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting updated installer statuses") + } + updatedInstaller.Status = statuses + + return updatedInstaller, nil +} + func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { if teamID == nil { return fleet.NewInvalidArgumentError("team_id", "is required") diff --git a/frontend/components/FileDetails/FileDetails.tsx b/frontend/components/FileDetails/FileDetails.tsx new file mode 100644 index 000000000000..78d48d5bdee8 --- /dev/null +++ b/frontend/components/FileDetails/FileDetails.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +import { ISupportedGraphicNames } from "components/FileUploader/FileUploader"; +import Graphic from "components/Graphic"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; + +interface IFileDetailsProps { + graphicNames: ISupportedGraphicNames | ISupportedGraphicNames[]; + fileDetails: { + name: string; + platform?: string; + }; + canEdit: boolean; + onFileSelect: (e: React.ChangeEvent) => void; + accept?: string; +} + +const baseClass = "file-details"; + +const FileDetails = ({ + graphicNames, + fileDetails, + canEdit, + onFileSelect, + accept, +}: IFileDetailsProps) => { + return ( +
+
+ +
+
{fileDetails.name}
+ {fileDetails.platform && ( +
+ {fileDetails.platform} +
+ )} +
+
+ {canEdit && ( +
+ + +
+ )} +
+ ); +}; + +export default FileDetails; diff --git a/frontend/components/FileDetails/_styles.scss b/frontend/components/FileDetails/_styles.scss new file mode 100644 index 000000000000..497dabcc3dbb --- /dev/null +++ b/frontend/components/FileDetails/_styles.scss @@ -0,0 +1,37 @@ +.file-details { + display: flex; + justify-content: space-between; + width: 100%; + + &__info { + display: flex; + gap: $pad-medium; + align-items: center; + width: 100%; + text-align: left; + } + + &__name { + font-size: $x-small; + font-weight: $bold; + } + + &__platform { + font-size: $xx-small; + color: $ui-fleet-black-75; + } + + &__edit { + display: flex; + align-items: center; // Center the button vertically + margin-right: -$pad-medium; // Adjust for button padding + } + + label { + display: flex; + + &:hover { + cursor: pointer; + } + } +} diff --git a/frontend/components/FileDetails/index.ts b/frontend/components/FileDetails/index.ts new file mode 100644 index 000000000000..434185761720 --- /dev/null +++ b/frontend/components/FileDetails/index.ts @@ -0,0 +1 @@ +export { default } from "./FileDetails"; diff --git a/frontend/components/FileUploader/FileUploader.tsx b/frontend/components/FileUploader/FileUploader.tsx index a79955df28b0..6ba3bfa42f02 100644 --- a/frontend/components/FileUploader/FileUploader.tsx +++ b/frontend/components/FileUploader/FileUploader.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useState } from "react"; +import React, { useState } from "react"; import classnames from "classnames"; import Button from "components/buttons/Button"; @@ -6,10 +6,11 @@ import Card from "components/Card"; import { GraphicNames } from "components/graphics"; import Icon from "components/Icon"; import Graphic from "components/Graphic"; +import FileDetails from "components/FileDetails"; const baseClass = "file-uploader"; -type ISupportedGraphicNames = Extract< +export type ISupportedGraphicNames = Extract< GraphicNames, | "file-configuration-profile" | "file-sh" @@ -23,29 +24,6 @@ type ISupportedGraphicNames = Extract< | "file-vpp" >; -export const FileDetails = ({ - details: { name, platform }, - graphicName = "file-pkg", -}: { - details: { - name: string; - platform?: string; - }; - graphicName?: ISupportedGraphicNames; -}) => ( -
- -
-
{name}
- {platform && ( -
- {platform} -
- )} -
-
-); - interface IFileUploaderProps { graphicName: ISupportedGraphicNames | ISupportedGraphicNames[]; message: string; @@ -53,8 +31,8 @@ interface IFileUploaderProps { /** Controls the loading spinner on the upload button */ isLoading?: boolean; /** Disables the upload button */ - diabled?: boolean; - /** A comma seperated string of one or more file types accepted to upload. + disabled?: boolean; + /** A comma separated string of one or more file types accepted to upload. * This is the same as the html accept attribute. * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept */ @@ -69,33 +47,36 @@ interface IFileUploaderProps { * @default "button" */ buttonType?: "button" | "link"; - /** If provided FileUploader will display this component when the file is - * selected. This is used for previewing the file before uploading. - */ - filePreview?: ReactNode; // TODO: refactor this to be a function that returns a ReactNode? onFileUpload: (files: FileList | null) => void; + /** renders the current file with the edit pencil button */ + canEdit?: boolean; + fileDetails?: { + name: string; + platform?: string; + }; } /** - * A component that encapsulates the UI for uploading a file. + * A component that encapsulates the UI for uploading a file and a file selected. */ export const FileUploader = ({ graphicName: graphicNames, message, additionalInfo, isLoading = false, - diabled = false, + disabled = false, accept, - filePreview, className, buttonMessage = "Upload", buttonType = "button", onFileUpload, + canEdit = false, + fileDetails, }: IFileUploaderProps) => { - const [isFileSelected, setIsFileSelected] = useState(false); + const [isFileSelected, setIsFileSelected] = useState(!!fileDetails); const classes = classnames(baseClass, className, { - [`${baseClass}__file-preview`]: filePreview !== undefined && isFileSelected, + [`${baseClass}__file-preview`]: isFileSelected, }); const buttonVariant = buttonType === "button" ? "brand" : "text-icon"; @@ -119,35 +100,47 @@ export const FileUploader = ({ )); }; + const renderFileUploader = () => { + return ( + <> +
{renderGraphics()}
+

{message}

+ {additionalInfo && ( +

{additionalInfo}

+ )} + + + + ); + }; + return ( - {isFileSelected && filePreview ? ( - filePreview + {isFileSelected && fileDetails ? ( + ) : ( - <> -
{renderGraphics()}
-

{message}

- {additionalInfo && ( -

{additionalInfo}

- )} - - - + renderFileUploader() )}
); diff --git a/frontend/components/FileUploader/_styles.scss b/frontend/components/FileUploader/_styles.scss index a02d768e5def..a9df15676be6 100644 --- a/frontend/components/FileUploader/_styles.scss +++ b/frontend/components/FileUploader/_styles.scss @@ -16,26 +16,6 @@ padding: $pad-medium $pad-large; } - &__selected-file { - display: flex; - gap: $pad-medium; - align-items: center; - width: 100%; - text-align: left; - - &--details { - &--name { - font-size: $x-small; - font-weight: $bold; - } - - &--platform { - font-size: $xx-small; - color: $ui-fleet-black-75; - } - } - } - &__graphics { display: flex; align-items: center; @@ -58,17 +38,17 @@ &__upload-button { // we handle the padding in the label so the entire button is clickable padding: 0; - } - label { - padding: $pad-small $pad-medium; - display: flex; - align-items: center; - justify-content: center; - gap: $pad-small; + label { + padding: $pad-small $pad-medium; + display: flex; + align-items: center; + justify-content: center; + gap: $pad-small; - &:hover { - cursor: pointer; + &:hover { + cursor: pointer; + } } } } diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 5072a4a37181..d8eacf643e7e 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -78,6 +78,7 @@ export enum ActivityType { EditedDeclarationProfile = "edited_declaration_profile", ResentConfigurationProfile = "resent_configuration_profile", AddedSoftware = "added_software", + EditedSoftware = "edited_software", DeletedSoftware = "deleted_software", InstalledSoftware = "installed_software", UninstalledSoftware = "uninstalled_software", diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx index c7ca0274021a..b12c50a80211 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx @@ -1057,6 +1057,29 @@ describe("Activity Feed", () => { expect(withNoTeams).toBeNull(); }); + it("renders an 'edited_script' type activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.EditedScript, + details: { team_name: "Alphas" }, + }); + render(); + + expect( + screen.getByText("edited scripts", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText(" for the ", { + exact: false, + }) + ).toBeInTheDocument(); + expect(screen.getByText("Alphas")).toBeInTheDocument(); + expect( + screen.getByText(" team via fleetctl.", { exact: false }) + ).toBeInTheDocument(); + const withNoTeams = screen.queryByText("no team"); + expect(withNoTeams).toBeNull(); + }); + it("renders a 'deleted_script' type activity for a team", () => { const activity = createMockActivity({ type: ActivityType.DeletedScript, @@ -1095,6 +1118,21 @@ describe("Activity Feed", () => { ).toBeInTheDocument(); }); + it("renders an 'edited_script' type activity for hosts with no team.", () => { + const activity = createMockActivity({ + type: ActivityType.EditedScript, + details: {}, + }); + render(); + + expect( + screen.getByText("edited scripts", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText("for no team via fleetctl.", { exact: false }) + ).toBeInTheDocument(); + }); + it("renders a 'deleted_script' type activity for hosts with no team.", () => { const activity = createMockActivity({ type: ActivityType.DeletedScript, @@ -1111,42 +1149,141 @@ describe("Activity Feed", () => { ).toBeInTheDocument(); }); - it("renders an 'edited_script' type activity for a team", () => { + it("renders an 'added_software' type activity for a team", () => { const activity = createMockActivity({ - type: ActivityType.EditedScript, - details: { team_name: "Alphas" }, + type: ActivityType.AddedSoftware, + details: { + software_title: "Foo bar", + software_package: "foobar.pkg", + team_name: "Alphas", + }, }); render(); expect( - screen.getByText("edited scripts", { exact: false }) + screen.getByText("added software ", { exact: false }) ).toBeInTheDocument(); expect( - screen.getByText(" for the ", { + screen.getByText("foobar.pkg", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText(" to the ", { exact: false, }) ).toBeInTheDocument(); expect(screen.getByText("Alphas")).toBeInTheDocument(); + expect(screen.getByText(" team.", { exact: false })).toBeInTheDocument(); + const withNoTeams = screen.queryByText("no team"); + expect(withNoTeams).toBeNull(); + }); + + it("renders an 'edited_software' type activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.EditedSoftware, + details: { + software_title: "Foo bar", + software_package: "foobar.pkg", + team_name: "Alphas", + }, + }); + render(); + expect( - screen.getByText(" team via fleetctl.", { exact: false }) + screen.getByText("edited software", { exact: false }) ).toBeInTheDocument(); + expect( + screen.getByText(" on the ", { + exact: false, + }) + ).toBeInTheDocument(); + expect(screen.getByText("Alphas")).toBeInTheDocument(); + expect(screen.getByText(" team.", { exact: false })).toBeInTheDocument(); const withNoTeams = screen.queryByText("no team"); expect(withNoTeams).toBeNull(); }); - it("renders an 'edited_script' type activity for hosts with no team.", () => { + + it("renders a 'deleted_software' type activity for a team", () => { const activity = createMockActivity({ - type: ActivityType.EditedScript, - details: {}, + type: ActivityType.DeletedSoftware, + details: { + software_title: "Foo bar", + software_package: "foobar.pkg", + team_name: "Alphas", + }, }); render(); expect( - screen.getByText("edited scripts", { exact: false }) + screen.getByText("deleted software ", { exact: false }) ).toBeInTheDocument(); expect( - screen.getByText("for no team via fleetctl.", { exact: false }) + screen.getByText("foobar.pkg", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText(" from the ", { + exact: false, + }) + ).toBeInTheDocument(); + expect(screen.getByText("Alphas")).toBeInTheDocument(); + expect(screen.getByText(" team.", { exact: false })).toBeInTheDocument(); + const withNoTeams = screen.queryByText("no team"); + expect(withNoTeams).toBeNull(); + }); + + it("renders an 'added_software' type activity for hosts with no team.", () => { + const activity = createMockActivity({ + type: ActivityType.AddedSoftware, + details: { software_title: "Foo bar", software_package: "foobar.pkg" }, + }); + render(); + + expect( + screen.getByText("added software ", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText("foobar.pkg", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText("to no team.", { exact: false }) ).toBeInTheDocument(); }); + + it("renders an 'edited_software' type activity for hosts with no team.", () => { + const activity = createMockActivity({ + type: ActivityType.EditedSoftware, + details: { + software_title: "Foo bar", + software_package: "foobar.pkg", + }, + }); + render(); + + expect( + screen.getByText("edited software", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText("on no team", { exact: false }) + ).toBeInTheDocument(); + }); + + it("renders a 'deleted_software' type activity for hosts with no team.", () => { + const activity = createMockActivity({ + type: ActivityType.DeletedSoftware, + details: { software_title: "Foo bar", software_package: "foobar.pkg" }, + }); + render(); + + expect( + screen.getByText("deleted software ", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText("foobar.pkg", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText("from no team.", { exact: false }) + ).toBeInTheDocument(); + }); + it("renders a pluralized 'deleted_multiple_saved_query' type activity when deleting multiple queries.", () => { const activity = createMockActivity({ type: ActivityType.DeletedMultipleSavedQuery, diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index ca521fdb9e0d..b6a59a417286 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -841,7 +841,7 @@ const TAGGED_TEMPLATES = { return ( <> {" "} - added {activity.details?.software_title} ( + added software {activity.details?.software_title} ( {activity.details?.software_package}) to{" "} {activity.details?.team_name ? ( <> @@ -854,11 +854,28 @@ const TAGGED_TEMPLATES = { ); }, + editedSoftware: (activity: IActivity) => { + return ( + <> + {" "} + edited software {activity.details?.software_title} ( + {activity.details?.software_package}) on{" "} + {activity.details?.team_name ? ( + <> + {" "} + the {activity.details?.team_name} team. + + ) : ( + "no team." + )} + + ); + }, deletedSoftware: (activity: IActivity) => { return ( <> {" "} - deleted {activity.details?.software_title} ( + deleted software {activity.details?.software_title} ( {activity.details?.software_package}) from{" "} {activity.details?.team_name ? ( <> @@ -1196,6 +1213,9 @@ const getDetail = ( case ActivityType.AddedSoftware: { return TAGGED_TEMPLATES.addedSoftware(activity); } + case ActivityType.EditedSoftware: { + return TAGGED_TEMPLATES.editedSoftware(activity); + } case ActivityType.DeletedSoftware: { return TAGGED_TEMPLATES.deletedSoftware(activity); } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/index.ts deleted file mode 100644 index 79a369995fb9..000000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./AdvancedOptionsModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx new file mode 100644 index 000000000000..d164f8dd8b89 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; + +import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm"; + +const baseClass = "save-changes-modal"; + +export interface IConfirmSaveChangesModalProps { + onSaveChanges: () => void; + softwarePackageName?: string; + onClose: () => void; +} + +const ConfirmSaveChangesModal = ({ + onSaveChanges, + softwarePackageName, + onClose, +}: IConfirmSaveChangesModalProps) => { + const warningText = ( + <> + The changes you are making will cancel any pending installs and uninstalls + {softwarePackageName ? ( + <> + {" "} + for {softwarePackageName} + + ) : ( + "" + )} + . + + ); + return ( + +
+

{warningText}

+

+ Installs or uninstalls currently running on a host will still + complete, but results won’t appear in Fleet. +

+

You cannot undo this action.

+
+ + +
+
+
+ ); +}; + +export default ConfirmSaveChangesModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/index.ts new file mode 100644 index 000000000000..c8c31da396be --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ConfirmSaveChangesModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ConfirmSaveChangesModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx new file mode 100644 index 000000000000..6950c7b9d174 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx @@ -0,0 +1,238 @@ +import React, { useContext, useState, useEffect } from "react"; +import { InjectedRouter } from "react-router"; +import classnames from "classnames"; +import deepDifference from "utilities/deep_difference"; + +import { getErrorReason } from "interfaces/errors"; + +import PATHS from "router/paths"; +import { NotificationContext } from "context/notification"; +import softwareAPI from "services/entities/software"; +import { QueryParams, buildQueryStringFromParams } from "utilities/url"; + +import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; + +import CustomLink from "components/CustomLink"; +import Modal from "components/Modal"; + +import PackageForm from "pages/SoftwarePage/components/PackageForm"; +import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm"; +import { + UPLOAD_TIMEOUT, + MAX_FILE_SIZE_MB, + MAX_FILE_SIZE_BYTES, +} from "pages/SoftwarePage/components/AddPackage/AddPackage"; +import { getErrorMessage } from "./helpers"; +import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal"; + +const baseClass = "edit-software-modal"; + +interface IEditSoftwareModalProps { + softwareId: number; + teamId: number; + router: InjectedRouter; + software?: any; // TODO + + onExit: () => void; + setAddedSoftwareToken: (token: string) => void; +} + +const EditSoftwareModal = ({ + softwareId, + teamId, + router, + software, + onExit, + setAddedSoftwareToken, +}: IEditSoftwareModalProps) => { + const { renderFlash } = useContext(NotificationContext); + + const [editSoftwareModalClasses, setEditSoftwareModalClasses] = useState( + baseClass + ); + const [isUpdatingSoftware, setIsUpdatingSoftware] = useState(false); + const [ + showConfirmSaveChangesModal, + setShowConfirmSaveChangesModal, + ] = useState(false); + const [pendingUpdates, setPendingUpdates] = useState({ + software: null, + installScript: "", + selfService: false, + }); + + // Work around to not lose Edit Software modal data when Save changes modal opens + // by using CSS to hide Edit Software modal when Save changes modal is open + useEffect(() => { + setEditSoftwareModalClasses( + classnames(baseClass, { + [`${baseClass}--hidden`]: showConfirmSaveChangesModal, + }) + ); + }, [showConfirmSaveChangesModal]); + + useEffect(() => { + let timeout: NodeJS.Timeout; + + const beforeUnloadHandler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + // Next line with e.returnValue is included for legacy support + // e.g.Chrome / Edge < 119 + e.returnValue = true; + }; + + // set up event listener to prevent user from leaving page while uploading + if (isUpdatingSoftware) { + addEventListener("beforeunload", beforeUnloadHandler); + timeout = setTimeout(() => { + removeEventListener("beforeunload", beforeUnloadHandler); + }, UPLOAD_TIMEOUT); + } else { + removeEventListener("beforeunload", beforeUnloadHandler); + } + + // clean up event listener and timeout on component unmount + return () => { + removeEventListener("beforeunload", beforeUnloadHandler); + clearTimeout(timeout); + }; + }, [isUpdatingSoftware]); + + const toggleConfirmSaveChangesModal = () => { + // open and closes save changes modal + setShowConfirmSaveChangesModal(!showConfirmSaveChangesModal); + }; + + const onSaveSoftwareChanges = async (formData: IPackageFormData) => { + setIsUpdatingSoftware(true); + + if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) { + renderFlash( + "error", + `Couldn't edit software. The maximum file size is ${MAX_FILE_SIZE_MB} MB.` + ); + setIsUpdatingSoftware(false); + return; + } + + // Note: This TODO is copied over from onAddPackage on AddPackage.tsx + // TODO: confirm we are deleting the second sentence (not modifying it) for non-self-service installers + try { + await softwareAPI.editSoftwarePackage( + formData, + softwareId, + teamId, + UPLOAD_TIMEOUT + ); + + renderFlash( + "success", + <> + Successfully edited {formData.software?.name}. + {formData.selfService + ? " The end user can install from Fleet Desktop." + : ""} + + ); + const newQueryParams: QueryParams = { team_id: teamId }; + if (formData.selfService) { + newQueryParams.self_service = true; + } else { + newQueryParams.available_for_install = true; + } + // any unique string - triggers SW refetch + setAddedSoftwareToken(`${Date.now()}`); + onExit(); + router.push( + `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}` + ); + } catch (e) { + const reason = getErrorReason(e); + if (reason.includes("Fleet couldn't read the version from")) { + renderFlash( + "error", + <> + Couldn't edit {software.name}. {reason}. + + + ); + } else if (reason.includes("selected package is")) { + renderFlash( + "error", + <> + Couldn't edit {software.name}. {reason} + + ); + } else { + renderFlash("error", getErrorMessage(e)); + } + } + setIsUpdatingSoftware(false); + }; + + const onEditSoftware = (formData: IPackageFormData) => { + // Check for changes to conditionally confirm save changes modal + const updates = deepDifference(formData, { + software, + installScript: software.install_script || "", + preInstallQuery: software.pre_install_query || "", + postInstallScript: software.post_install_script || "", + uninstallScript: software.uninstall_script || "", + selfService: software.self_service || false, + }); + + setPendingUpdates(formData); + + const onlySelfServiceUpdated = + Object.keys(updates).length === 1 && "selfService" in updates; + if (!onlySelfServiceUpdated) { + console.log("non-self-service updates: ", updates); + // Open the confirm save changes modal + setShowConfirmSaveChangesModal(true); + } else { + // Proceed with saving changes (API expects only changes) + onSaveSoftwareChanges(formData); + } + }; + + const onConfirmSoftwareChanges = () => { + setShowConfirmSaveChangesModal(false); + onSaveSoftwareChanges(pendingUpdates); + }; + + return ( + <> + + + + {showConfirmSaveChangesModal && ( + + )} + + ); +}; + +export default EditSoftwareModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/_styles.scss similarity index 66% rename from frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/_styles.scss rename to frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/_styles.scss index a63d438bdf8c..6dd125240355 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/_styles.scss @@ -1,4 +1,4 @@ -.advanced-options-modal { +.edit-software-modal { &__form-inputs { display: flex; flex-direction: column; @@ -8,6 +8,10 @@ &__input-field { display: flex; flex-direction: column; - gap: $pad-medium + gap: $pad-medium; + } + + &--hidden { + display: none; } } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/helpers.ts new file mode 100644 index 000000000000..967bfb088245 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/helpers.ts @@ -0,0 +1,13 @@ +import { getErrorReason } from "interfaces/errors"; + +const UPLOAD_ERROR_MESSAGES = { + default: { + message: "Couldn't edit software. Please try again.", + }, +}; + +// eslint-disable-next-line import/prefer-default-export +export const getErrorMessage = (err: unknown) => { + if (typeof err === "string") return err; + return getErrorReason(err) || UPLOAD_ERROR_MESSAGES.default.message; +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/index.ts new file mode 100644 index 000000000000..88fca9330c13 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/index.ts @@ -0,0 +1 @@ +export { default } from "./EditSoftwareModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 0229550957ee..e5062e0505cd 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -4,6 +4,7 @@ import React, { useLayoutEffect, useState, } from "react"; +import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { AppContext } from "context/app"; @@ -14,6 +15,7 @@ import softwareAPI from "services/entities/software"; import { buildQueryStringFromParams } from "utilities/url"; import { internationalTimeFormat } from "utilities/helpers"; import { uploadedFromNow } from "utilities/date_format"; +import { noop } from "lodash"; // @ts-ignore import Dropdown from "components/forms/fields/Dropdown"; @@ -28,10 +30,10 @@ import endpoints from "utilities/endpoints"; import URL_PREFIX from "router/url_prefix"; import DeleteSoftwareModal from "../DeleteSoftwareModal"; -import AdvancedOptionsModal from "../AdvancedOptionsModal"; +import EditSoftwareModal from "../EditSoftwareModal"; import { APP_STORE_APP_DROPDOWN_OPTIONS, - SOFTWARE_PACAKGE_DROPDOWN_OPTIONS, + SOFTWARE_PACKAGE_DROPDOWN_OPTIONS, downloadFile, } from "./helpers"; @@ -179,14 +181,14 @@ interface IActionsDropdownProps { isSoftwarePackage: boolean; onDownloadClick: () => void; onDeleteClick: () => void; - onAdvancedOptionsClick: () => void; + onEditSoftwareClick: () => void; } const ActionsDropdown = ({ isSoftwarePackage, onDownloadClick, onDeleteClick, - onAdvancedOptionsClick, + onEditSoftwareClick, }: IActionsDropdownProps) => { const onSelect = (value: string) => { switch (value) { @@ -196,8 +198,8 @@ const ActionsDropdown = ({ case "delete": onDeleteClick(); break; - case "advanced": - onAdvancedOptionsClick(); + case "edit": + onEditSoftwareClick(); break; default: // noop @@ -213,7 +215,7 @@ const ActionsDropdown = ({ searchable={false} options={ isSoftwarePackage - ? SOFTWARE_PACAKGE_DROPDOWN_OPTIONS + ? SOFTWARE_PACKAGE_DROPDOWN_OPTIONS : APP_STORE_APP_DROPDOWN_OPTIONS } /> @@ -236,9 +238,10 @@ interface ISoftwarePackageCardProps { // NOTE: we will only have this if we are working with a software package. softwarePackage?: ISoftwarePackage; onDelete: () => void; + router: InjectedRouter; } -// NOTE: This component is depeent on having either a software package +// 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 SoftwarePackageCard = ({ @@ -251,6 +254,7 @@ const SoftwarePackageCard = ({ softwareId, teamId, onDelete, + router, }: ISoftwarePackageCardProps) => { const { isGlobalAdmin, @@ -260,13 +264,11 @@ const SoftwarePackageCard = ({ } = useContext(AppContext); const { renderFlash } = useContext(NotificationContext); - const [showAdvancedOptionsModal, setShowAdvancedOptionsModal] = useState( - false - ); + const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); - const onAdvancedOptionsClick = () => { - setShowAdvancedOptionsModal(true); + const onEditSoftwareClick = () => { + setShowEditSoftwareModal(true); }; const onDeleteClick = () => { @@ -334,7 +336,7 @@ const SoftwarePackageCard = ({
{renderIcon()}
- + {renderDetails()}
@@ -354,7 +356,7 @@ const SoftwarePackageCard = ({ isSoftwarePackage={!!softwarePackage} onDownloadClick={onDownloadClick} onDeleteClick={onDeleteClick} - onAdvancedOptionsClick={onAdvancedOptionsClick} + onEditSoftwareClick={onEditSoftwareClick} /> )} @@ -379,12 +381,14 @@ const SoftwarePackageCard = ({ teamId={teamId} /> - {showAdvancedOptionsModal && ( - setShowAdvancedOptionsModal(false)} + {showEditSoftwareModal && ( + setShowEditSoftwareModal(false)} + router={router} + setAddedSoftwareToken={noop} /> )} {showDeleteModal && ( diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/helpers.ts index db049c2a9d69..e1bf20690154 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/helpers.ts @@ -1,15 +1,15 @@ -export const SOFTWARE_PACAKGE_DROPDOWN_OPTIONS = [ +export const SOFTWARE_PACKAGE_DROPDOWN_OPTIONS = [ { label: "Download", value: "download", }, { - label: "Delete", - value: "delete", + label: "Edit", + value: "edit", }, { - label: "Advanced options", - value: "advanced", + label: "Delete", + value: "delete", }, ] as const; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index ae2084e6b726..11c83519d947 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -151,6 +151,7 @@ const SoftwareTitleDetailsPage = ({ softwareId={softwareId} teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID} onDelete={onDeleteInstaller} + router={router} /> ); } diff --git a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx index 52cb805f7f46..d96b81e69bbb 100644 --- a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx @@ -12,16 +12,16 @@ import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; import CustomLink from "components/CustomLink"; -import AddPackageForm from "../AddPackageForm"; -import { IAddPackageFormData } from "../AddPackageForm/AddPackageForm"; +import PackageForm from "../PackageForm"; +import { IPackageFormData } from "../PackageForm/PackageForm"; import { getErrorMessage } from "../AddSoftwareModal/helpers"; const baseClass = "add-package"; // 8 minutes + 15 seconds to account for extra roundtrip time. -const UPLOAD_TIMEOUT = (8 * 60 + 15) * 1000; -const MAX_FILE_SIZE_MB = 500; -const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; +export const UPLOAD_TIMEOUT = (8 * 60 + 15) * 1000; +export const MAX_FILE_SIZE_MB = 500; +export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; interface IAddPackageProps { teamId: number; @@ -66,7 +66,7 @@ const AddPackage = ({ }; }, [isUploading]); - const onAddPackage = async (formData: IAddPackageFormData) => { + const onAddPackage = async (formData: IPackageFormData) => { setIsUploading(true); if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) { @@ -79,6 +79,7 @@ const AddPackage = ({ return; } + // Note: This TODO is copied to onSaveSoftwareChanges in EditSoftwareModal // TODO: confirm we are deleting the second sentence (not modifying it) for non-self-service installers try { await softwareAPI.addSoftwarePackage(formData, teamId, UPLOAD_TIMEOUT); @@ -128,7 +129,7 @@ const AddPackage = ({ return (
- { return `Currently, ${ @@ -58,11 +58,11 @@ const getUninstallHelpText = (pkgType: PackageType) => { ); }; -const baseClass = "add-package-advanced-options"; +const baseClass = "package-advanced-options"; -interface IAddPackageAdvancedOptionsProps { +interface IPackageAdvancedOptionsProps { errors: { preInstallQuery?: string; postInstallScript?: string }; - selectedPackage: IAddPackageFormData["software"]; + selectedPackage: IPackageFormData["software"]; preInstallQuery?: string; installScript: string; postInstallScript?: string; @@ -73,7 +73,7 @@ interface IAddPackageAdvancedOptionsProps { onChangeUninstallScript: (value?: string) => void; } -const AddPackageAdvancedOptions = ({ +const PackageAdvancedOptions = ({ errors, selectedPackage, preInstallQuery, @@ -84,7 +84,7 @@ const AddPackageAdvancedOptions = ({ onChangeInstallScript, onChangePostInstallScript, onChangeUninstallScript, -}: IAddPackageAdvancedOptionsProps) => { +}: IPackageAdvancedOptionsProps) => { const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const renderAdvancedOptions = () => { @@ -176,4 +176,4 @@ const AddPackageAdvancedOptions = ({ ); }; -export default AddPackageAdvancedOptions; +export default PackageAdvancedOptions; diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss b/frontend/pages/SoftwarePage/components/PackageAdvancedOptions/_styles.scss similarity index 88% rename from frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss rename to frontend/pages/SoftwarePage/components/PackageAdvancedOptions/_styles.scss index 0728e3241560..99167137dae5 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss +++ b/frontend/pages/SoftwarePage/components/PackageAdvancedOptions/_styles.scss @@ -1,4 +1,4 @@ -.add-package-advanced-options { +.package-advanced-options { display: flex; flex-direction: column; align-items: flex-start; diff --git a/frontend/pages/SoftwarePage/components/PackageAdvancedOptions/index.ts b/frontend/pages/SoftwarePage/components/PackageAdvancedOptions/index.ts new file mode 100644 index 000000000000..884237783f2c --- /dev/null +++ b/frontend/pages/SoftwarePage/components/PackageAdvancedOptions/index.ts @@ -0,0 +1 @@ +export { default } from "./PackageAdvancedOptions"; diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx similarity index 60% rename from frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx rename to frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx index 3fe7922fbb17..343423d2d829 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx @@ -1,3 +1,4 @@ +// Used in AddPackageModal.tsx and EditSoftwareModal.tsx import React, { useContext, useState } from "react"; import { NotificationContext } from "context/notification"; @@ -7,29 +8,26 @@ import getDefaultUninstallScript from "utilities/software_uninstall_scripts"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; -import { - FileUploader, - FileDetails, -} from "components/FileUploader/FileUploader"; +import FileUploader from "components/FileUploader"; import Spinner from "components/Spinner"; import TooltipWrapper from "components/TooltipWrapper"; -import AddPackageAdvancedOptions from "../AddPackageAdvancedOptions"; +import PackageAdvancedOptions from "../PackageAdvancedOptions"; import { generateFormValidation } from "./helpers"; -export const baseClass = "add-package-form"; +export const baseClass = "package-form"; const UploadingSoftware = () => { return (
-

Adding software. This may take a few minutes to finish.

+

Uploading software. This may take a few minutes to finish.

); }; -export interface IAddPackageFormData { +export interface IPackageFormData { software: File | null; preInstallQuery?: string; installScript: string; @@ -43,30 +41,48 @@ export interface IFormValidation { software: { isValid: boolean }; preInstallQuery?: { isValid: boolean; message?: string }; postInstallScript?: { isValid: boolean; message?: string }; + uninstallScript?: { isValid: boolean; message?: string }; selfService?: { isValid: boolean }; } -interface IAddPackageFormProps { +interface IPackageFormProps { isUploading: boolean; onCancel: () => void; - onSubmit: (formData: IAddPackageFormData) => void; + onSubmit: (formData: IPackageFormData) => void; + isEditingSoftware?: boolean; + defaultSoftware?: any; // TODO + defaultInstallScript?: string; + defaultPreInstallQuery?: string; + defaultPostInstallScript?: string; + defaultUninstallScript?: string; + defaultSelfService?: boolean; } -const AddPackageForm = ({ +const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb"; + +const PackageForm = ({ isUploading, onCancel, onSubmit, -}: IAddPackageFormProps) => { + isEditingSoftware = false, + defaultSoftware, + defaultInstallScript, + defaultPreInstallQuery, + defaultPostInstallScript, + defaultUninstallScript, + defaultSelfService, +}: IPackageFormProps) => { const { renderFlash } = useContext(NotificationContext); - const [formData, setFormData] = useState({ - software: null, - preInstallQuery: undefined, - installScript: "", - postInstallScript: undefined, - uninstallScript: undefined, - selfService: false, - }); + const initialFormData = { + software: defaultSoftware || null, + installScript: defaultInstallScript || "", + preInstallQuery: defaultPreInstallQuery || "", + postInstallScript: defaultPostInstallScript || "", + uninstallScript: defaultUninstallScript || "", + selfService: defaultSelfService || false, + }; + const [formData, setFormData] = useState(initialFormData); const [formValidation, setFormValidation] = useState({ isValid: false, software: { isValid: false }, @@ -76,30 +92,37 @@ const AddPackageForm = ({ if (files && files.length > 0) { const file = files[0]; - let defaultInstallScript: string; - try { - defaultInstallScript = getDefaultInstallScript(file.name); - } catch (e) { - renderFlash("error", `${e}`); - return; - } - - let defaultUninstallScript: string; - try { - defaultUninstallScript = getDefaultUninstallScript(file.name); - } catch (e) { - renderFlash("error", `${e}`); - return; + // Only populate default install/uninstall scripts when adding (but not editing) software + if (isEditingSoftware) { + const newData = { ...formData, software: file }; + setFormData(newData); + setFormValidation(generateFormValidation(newData)); + } else { + let newDefaultInstallScript: string; + try { + newDefaultInstallScript = getDefaultInstallScript(file.name); + } catch (e) { + renderFlash("error", `${e}`); + return; + } + + let newDefaultUninstallScript: string; + try { + newDefaultUninstallScript = getDefaultUninstallScript(file.name); + } catch (e) { + renderFlash("error", `${e}`); + return; + } + + const newData = { + ...formData, + software: file, + installScript: newDefaultInstallScript || "", + uninstallScript: newDefaultUninstallScript || "", + }; + setFormData(newData); + setFormValidation(generateFormValidation(newData)); } - - const newData = { - ...formData, - software: file, - installScript: defaultInstallScript, - uninstallScript: defaultUninstallScript, - }; - setFormData(newData); - setFormValidation(generateFormValidation(newData)); } }; @@ -109,7 +132,9 @@ const AddPackageForm = ({ }; const onChangeInstallScript = (value: string) => { - setFormData({ ...formData, installScript: value }); + const newData = { ...formData, installScript: value }; + setFormData(newData); + setFormValidation(generateFormValidation(newData)); }; const onChangePreInstallQuery = (value?: string) => { @@ -141,21 +166,20 @@ const AddPackageForm = ({ return (
{isUploading ? ( - + // Note: Sarah is replacing uploading state as subsequent 4.57 feature ) : (
- ) + fileDetails={ + formData.software ? getFileDetails(formData.software) : undefined } /> -