Skip to content

Commit

Permalink
Edit script modal (#25926)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
dantecatalfamo authored Feb 3, 2025
1 parent f035821 commit de58010
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 14 deletions.
1 change: 1 addition & 0 deletions changes/24601-editable-scripts-frontend
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added modal to edit script contents
1 change: 1 addition & 0 deletions frontend/interfaces/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/interfaces/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface ILastExecution {
status: IScriptExecutionStatus;
}

export type ScriptContent = string;

export interface IHostScript {
script_id: number;
name: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -701,6 +701,31 @@ const TAGGED_TEMPLATES = {
</>
);
},
updatedScript: (activity: IActivity) => {
const scriptName = activity.details?.script_name;
return (
<>
{" "}
edited{" "}
{scriptName ? (
<>
script <b>{scriptName}</b>{" "}
</>
) : (
"a script "
)}
for{" "}
{activity.details?.team_name ? (
<>
the <b>{activity.details.team_name}</b> team
</>
) : (
"no team"
)}
.
</>
);
},
deletedScript: (activity: IActivity) => {
const scriptName = activity.details?.script_name;
return (
Expand Down Expand Up @@ -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);
}
Expand Down
32 changes: 26 additions & 6 deletions frontend/pages/ManageControlsPage/Scripts/Scripts.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<IScript | null>(null);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -142,6 +154,7 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => {
script={listItem}
onDelete={onClickDelete}
onClickScript={onClickScript}
onEdit={onEditScript}
/>
)}
/>
Expand Down Expand Up @@ -202,6 +215,13 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => {
runScriptHelpText
/>
)}
{showEditScripsModal && selectedScript.current && (
<EditScriptModal
scriptId={selectedScript.current.id}
scriptName={selectedScript.current.name}
onExit={onExitEditScript}
/>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

const {
error: isSelectedScriptContentError,
isLoading: isLoadingSelectedScriptContent,
} = useQuery<ScriptContent, Error>(
[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<HTMLFormElement>) => {
e.preventDefault();
onSave();
};

const renderContent = () => {
if (isLoadingSelectedScriptContent) {
return <Spinner />;
}

if (isSelectedScriptContentError) {
return <DataError description="Close this modal and try again." />;
}

return (
<>
<form onSubmit={onSubmit}>
<Editor
value={scriptFormData}
onChange={onChange}
isFormField
error={formError}
/>
<div className="form-field__help-text">
To run this script on a host, go to the{" "}
<CustomLink text="Hosts" url={paths.MANAGE_HOSTS} /> page and select
a host.
<br />
To run the script across multiple hosts, add a policy automation on
the <CustomLink text="Policies" url={paths.MANAGE_POLICIES} /> page.
</div>
</form>
<ModalFooter
primaryButtons={
<>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
<Button
onClick={onSave}
variant="brand"
isLoading={isSubmitting}
disabled={!!formError}
>
Save
</Button>
</>
}
/>
</>
);
};

return (
<Modal
className={baseClass}
title={scriptName}
width="large"
onExit={onExit}
>
{renderContent()}
</Modal>
);
};

export default EditScriptModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./EditScriptModal";
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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();
Expand All @@ -32,6 +33,7 @@ describe("ScriptListItem", () => {
<ScriptListItem
script={MAC_SCRIPT}
onDelete={onDelete}
onEdit={onEdit}
onClickScript={onClickScript}
/>
);
Expand All @@ -44,6 +46,7 @@ describe("ScriptListItem", () => {
<ScriptListItem
script={WINDOWS_SCRIPT}
onDelete={onDelete}
onEdit={onEdit}
onClickScript={onClickScript}
/>
);
Expand All @@ -56,6 +59,7 @@ describe("ScriptListItem", () => {
<ScriptListItem
script={MAC_SCRIPT}
onDelete={onDelete}
onEdit={onEdit}
onClickScript={onClickScript}
/>
);
Expand All @@ -69,11 +73,26 @@ describe("ScriptListItem", () => {
<ScriptListItem
script={MAC_SCRIPT}
onDelete={onDelete}
onEdit={onEdit}
onClickScript={onClickScript}
/>
);

fireEvent.click(screen.getByTestId("trash-icon"));
expect(onDelete).toHaveBeenCalledWith(MAC_SCRIPT);
});

it("calls onEdit when pencil button is clicked", () => {
render(
<ScriptListItem
script={MAC_SCRIPT}
onDelete={onDelete}
onEdit={onEdit}
onClickScript={onClickScript}
/>
);

fireEvent.click(screen.getByTestId("pencil-icon"));
expect(onEdit).toHaveBeenCalledWith(MAC_SCRIPT);
});
});
Loading

0 comments on commit de58010

Please sign in to comment.