From de580105106e6e94da058f99b5874826b2676a43 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:27:44 -0500 Subject: [PATCH] Edit script modal (#25926) For #24601 - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] A detailed QA plan exists on the associated ticket (if it isn't there, work with the product group's QA engineer to add it) - Click pencil - Edit script - Save - Check script was saved - Check activities - [x] Manual QA for all new/changed functionality --- changes/24601-editable-scripts-frontend | 1 + frontend/interfaces/activity.ts | 1 + frontend/interfaces/script.ts | 2 + .../GlobalActivityItem/GlobalActivityItem.tsx | 32 +++- .../ManageControlsPage/Scripts/Scripts.tsx | 32 +++- .../EditScriptModal/EditScriptModal.tsx | 147 ++++++++++++++++++ .../components/EditScriptModal/index.ts | 1 + .../ScriptListItem/ScriptListItem.tests.tsx | 23 ++- .../ScriptListItem/ScriptListItem.tsx | 15 +- frontend/services/entities/scripts.ts | 13 +- 10 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 changes/24601-editable-scripts-frontend create mode 100644 frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx create mode 100644 frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/index.ts diff --git a/changes/24601-editable-scripts-frontend b/changes/24601-editable-scripts-frontend new file mode 100644 index 000000000000..a2f472378077 --- /dev/null +++ b/changes/24601-editable-scripts-frontend @@ -0,0 +1 @@ +- Added modal to edit script contents diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index d22e9007bd4c..e8cfd1300f15 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -74,6 +74,7 @@ export enum ActivityType { DisabledWindowsMdmMigration = "disabled_windows_mdm_migration", RanScript = "ran_script", AddedScript = "added_script", + UpdatedScript = "updated_script", DeletedScript = "deleted_script", EditedScript = "edited_script", EditedWindowsUpdates = "edited_windows_updates", diff --git a/frontend/interfaces/script.ts b/frontend/interfaces/script.ts index 67d9572052ad..15497dcc9036 100644 --- a/frontend/interfaces/script.ts +++ b/frontend/interfaces/script.ts @@ -19,6 +19,8 @@ export interface ILastExecution { status: IScriptExecutionStatus; } +export type ScriptContent = string; + export interface IHostScript { script_id: number; name: string; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index b141c2cd1343..3e27f57a7aa5 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -1,12 +1,12 @@ -import React from "react"; import { find, lowerCase, noop, trimEnd } from "lodash"; +import React from "react"; import { ActivityType, IActivity } from "interfaces/activity"; -import { getInstallStatusPredicate } from "interfaces/software"; import { AppleDisplayPlatform, PLATFORM_DISPLAY_NAMES, } from "interfaces/platform"; +import { getInstallStatusPredicate } from "interfaces/software"; import { formatScriptNameForActivityItem, getPerformanceImpactDescription, @@ -701,6 +701,31 @@ const TAGGED_TEMPLATES = { ); }, + updatedScript: (activity: IActivity) => { + const scriptName = activity.details?.script_name; + return ( + <> + {" "} + edited{" "} + {scriptName ? ( + <> + script {scriptName}{" "} + + ) : ( + "a script " + )} + for{" "} + {activity.details?.team_name ? ( + <> + the {activity.details.team_name} team + + ) : ( + "no team" + )} + . + + ); + }, deletedScript: (activity: IActivity) => { const scriptName = activity.details?.script_name; return ( @@ -1197,6 +1222,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.AddedScript: { return TAGGED_TEMPLATES.addedScript(activity); } + case ActivityType.UpdatedScript: { + return TAGGED_TEMPLATES.updatedScript(activity); + } case ActivityType.DeletedScript: { return TAGGED_TEMPLATES.deletedScript(activity); } diff --git a/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx b/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx index 0d879db750ca..38582004f053 100644 --- a/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx @@ -1,26 +1,27 @@ +import { AxiosError } from "axios"; import React, { useCallback, useContext, useRef, useState } from "react"; import { useQuery } from "react-query"; -import { AxiosError } from "axios"; import { InjectedRouter } from "react-router"; import { AppContext } from "context/app"; +import { IScript } from "interfaces/script"; import PATHS from "router/paths"; import scriptAPI, { IListScriptsQueryKey, IScriptsResponse, } from "services/entities/scripts"; -import { IScript } from "interfaces/script"; import CustomLink from "components/CustomLink"; -import Spinner from "components/Spinner"; import DataError from "components/DataError"; import InfoBanner from "components/InfoBanner"; +import Spinner from "components/Spinner"; +import UploadList from "../components/UploadList"; +import DeleteScriptModal from "./components/DeleteScriptModal"; +import EditScriptModal from "./components/EditScriptModal"; +import ScriptDetailsModal from "./components/ScriptDetailsModal"; import ScriptListHeading from "./components/ScriptListHeading"; import ScriptListItem from "./components/ScriptListItem"; import ScriptListPagination from "./components/ScriptListPagination"; -import DeleteScriptModal from "./components/DeleteScriptModal"; -import ScriptDetailsModal from "./components/ScriptDetailsModal"; -import UploadList from "../components/UploadList"; import ScriptUploader from "./components/ScriptUploader"; const baseClass = "scripts"; @@ -37,6 +38,7 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => { const { isPremiumTier } = useContext(AppContext); const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false); const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false); + const [showEditScripsModal, setShowEditScriptModal] = useState(false); const [goBackToScriptDetails, setGoBackToScriptDetails] = useState(false); // Used for onCancel in delete modal const selectedScript = useRef(null); @@ -93,6 +95,16 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => { setGoBackToScriptDetails(false); }; + const onEditScript = (script: IScript) => { + selectedScript.current = script; + setShowEditScriptModal(true); + }; + + const onExitEditScript = () => { + selectedScript.current = null; + setShowEditScriptModal(false); + }; + const onClickDelete = (script: IScript) => { selectedScript.current = script; setShowDeleteScriptModal(true); @@ -142,6 +154,7 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => { script={listItem} onDelete={onClickDelete} onClickScript={onClickScript} + onEdit={onEditScript} /> )} /> @@ -202,6 +215,13 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => { runScriptHelpText /> )} + {showEditScripsModal && selectedScript.current && ( + + )} ); }; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx new file mode 100644 index 000000000000..9aae777c51f7 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx @@ -0,0 +1,147 @@ +import React, { useContext, useState } from "react"; +import { useQuery } from "react-query"; + +import { NotificationContext } from "context/notification"; +import scriptAPI from "services/entities/scripts"; + +import Button from "components/buttons/Button"; +import CustomLink from "components/CustomLink"; +import DataError from "components/DataError"; +import Editor from "components/Editor"; +import Modal from "components/Modal"; +import ModalFooter from "components/ModalFooter"; +import Spinner from "components/Spinner"; +import paths from "router/paths"; + +import { ScriptContent } from "interfaces/script"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import { getErrorMessage } from "../ScriptUploader/helpers"; + +const baseClass = "edit-script-modal"; + +interface IEditScriptModal { + onExit: () => void; + scriptId: number; + scriptName: string; +} + +const validate = (scriptContent: string) => { + if (scriptContent.trim() === "") { + return "Script cannot be empty"; + } + return null; +}; + +const EditScriptModal = ({ + scriptId, + scriptName, + onExit, +}: IEditScriptModal) => { + const { renderFlash } = useContext(NotificationContext); + + // Editable script content + const [scriptFormData, setScriptFormData] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + const { + error: isSelectedScriptContentError, + isLoading: isLoadingSelectedScriptContent, + } = useQuery( + [scriptId], + () => scriptAPI.downloadScript(scriptId), + { + ...DEFAULT_USE_QUERY_OPTIONS, + onSuccess: (scriptContent) => { + setScriptFormData(scriptContent); + }, + } + ); + + const onChange = (value: string) => { + setScriptFormData(value); + setFormError(validate(value)); + }; + + const onSave = async () => { + if (isSubmitting) { + return; + } + try { + setIsSubmitting(true); + await scriptAPI.updateScript(scriptId, scriptFormData, scriptName); + renderFlash("success", "Successfully saved script."); + onExit(); + } catch (e) { + renderFlash("error", getErrorMessage(e)); + } finally { + setIsSubmitting(false); + } + }; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + onSave(); + }; + + const renderContent = () => { + if (isLoadingSelectedScriptContent) { + return ; + } + + if (isSelectedScriptContentError) { + return ; + } + + return ( + <> +
+ +
+ To run this script on a host, go to the{" "} + page and select + a host. +
+ To run the script across multiple hosts, add a policy automation on + the page. +
+ + + + + + } + /> + + ); + }; + + return ( + + {renderContent()} + + ); +}; + +export default EditScriptModal; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/index.ts b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/index.ts new file mode 100644 index 000000000000..896d3c26f17d --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/index.ts @@ -0,0 +1 @@ +export { default } from "./EditScriptModal"; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tests.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tests.tsx index 2e2d1ff346bb..47d33deee8f3 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tests.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tests.tsx @@ -1,6 +1,6 @@ -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { IScript } from "interfaces/script"; +import React from "react"; import ScriptListItem from "./ScriptListItem"; const MAC_SCRIPT: IScript = { @@ -22,6 +22,7 @@ const WINDOWS_SCRIPT: IScript = { describe("ScriptListItem", () => { const onDelete = jest.fn(); const onClickScript = jest.fn(); + const onEdit = jest.fn(); beforeEach(() => { jest.clearAllMocks(); @@ -32,6 +33,7 @@ describe("ScriptListItem", () => { ); @@ -44,6 +46,7 @@ describe("ScriptListItem", () => { ); @@ -56,6 +59,7 @@ describe("ScriptListItem", () => { ); @@ -69,6 +73,7 @@ describe("ScriptListItem", () => { ); @@ -76,4 +81,18 @@ describe("ScriptListItem", () => { fireEvent.click(screen.getByTestId("trash-icon")); expect(onDelete).toHaveBeenCalledWith(MAC_SCRIPT); }); + + it("calls onEdit when pencil button is clicked", () => { + render( + + ); + + fireEvent.click(screen.getByTestId("pencil-icon")); + expect(onEdit).toHaveBeenCalledWith(MAC_SCRIPT); + }); }); diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx index 1f077099db1d..75c13d6ce3f3 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx @@ -1,13 +1,13 @@ -import React, { useContext } from "react"; import { format, formatDistanceToNow } from "date-fns"; import FileSaver from "file-saver"; +import React, { useContext } from "react"; import { NotificationContext } from "context/notification"; -import scriptAPI from "services/entities/scripts"; import { IScript } from "interfaces/script"; +import scriptAPI from "services/entities/scripts"; -import Icon from "components/Icon"; import Button from "components/buttons/Button"; +import Icon from "components/Icon"; import ListItem from "components/ListItem"; import { ISupportedGraphicNames } from "components/ListItem/ListItem"; @@ -17,6 +17,7 @@ interface IScriptListItemProps { script: IScript; onDelete: (script: IScript) => void; onClickScript: (script: IScript) => void; + onEdit: (script: IScript) => void; } // TODO - useful to have a 'platform' field from API, for use elsewhere in app as well? @@ -73,6 +74,7 @@ const ScriptListItem = ({ script, onDelete, onClickScript, + onEdit, }: IScriptListItemProps) => { const { renderFlash } = useContext(NotificationContext); @@ -95,6 +97,13 @@ const ScriptListItem = ({ } actions={ <> +