fleet/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx
Scott Gress 81f589d661
Update add script UI (#34349)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #32632

# Details

This PR updates the Script Library page in the following ways:
* When no scripts are uploaded for a team, it shows the "Add script" UI
with a button that opens a new "Add Script" modal
* When scripts are uploaded, the "Add script" button is instead added to
the header of the scripts list, and clicking it opens that modal

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [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/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [ ] Added/updated automated tests
working on this

- [X] QA'd all new/changed functionality manually
- [X] Test empty state: go to controls/scripts/library for a team with
no scripts. Clicking "upload" button in empty state should open the add
script modal.
- [X] In the modal, select a .ps1 script. Should not see additional
text.
- [X] Close modal without uploading. Re-open. File field should be
cleared & upload button visible again.
- [X] Select a .sh script. Should see additional text about macOS and
Linux.
  - [X] Add script. Make sure script saves and modal closes.
- [X] Once script has been added, make sure empty state is gone and "Add
script" button is at the top of the list.
- [X] Go to /controls/os-settings/custom-settings for a team with no
profiles uploaded. Make sure empty state text styles match the empty
state for script uploads.
- [X] Open modal to add profile. Make sure upload text styles match the
script upload modal.
- [X] Enable GitOps mode. Go to controls/scripts/library for a team with
scripts added. Make sure new "Add script" button is disabled w/ standard
tooltip in GitOps mode.

Scripts empty state:

<img width="697" height="352" alt="image"
src="https://github.com/user-attachments/assets/32f0f246-bddb-4bb7-bc39-48d9978de9fa"
/>

Scripts uploader:

<img width="745" height="590" alt="image"
src="https://github.com/user-attachments/assets/f82414e2-9318-4543-b5ca-41e759662587"
/>

Scripts uploader with .sh

<img width="750" height="539" alt="image"
src="https://github.com/user-attachments/assets/0b989067-921a-4d18-93ed-09aac90fc9cb"
/>

Scripts table:

<img width="686" height="256" alt="image"
src="https://github.com/user-attachments/assets/848f1b56-6e9e-48d4-9a03-6fdf5427301e"
/>

Profiles empty state:

<img width="700" height="377" alt="image"
src="https://github.com/user-attachments/assets/8f92bcd9-2215-41f6-a540-4774f7e9542b"
/>

Profiles uploader:

<img width="707" height="682" alt="image"
src="https://github.com/user-attachments/assets/eef216af-3447-48e7-882a-e42e888e1c17"
/>
2025-10-17 10:49:59 -05:00

246 lines
6.4 KiB
TypeScript

import React, { useContext, useState } from "react";
import { useQuery } from "react-query";
import classnames from "classnames";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import { getPathWithQueryParams } from "utilities/url";
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 "../ScriptUploadModal/helpers";
const baseClass = "edit-script-modal";
interface IWarningModal {
onExit: () => void;
onSave: () => void;
scriptName: string;
isSubmitting: boolean;
}
const WarningModal = ({
onExit,
onSave,
scriptName,
isSubmitting,
}: IWarningModal) => {
return (
<Modal
className={`${baseClass}__warning`}
title="Save changes?"
onExit={onExit}
>
<>
<p>
The changes you are making will cancel any pending script runs for{" "}
<b>{scriptName}</b>.<br />
<br />
If this script is currently running on a host, it will complete, but
results won&apos;t appear in Fleet. <br />
<br />
You cannot undo this action.
</p>
<div className="modal-cta-wrap">
<Button
type="button"
onClick={onSave}
className="save-loading"
isLoading={isSubmitting}
>
Save
</Button>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
</div>
</>
</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);
const { currentTeam } = useContext(AppContext);
// Editable script content
const [scriptFormData, setScriptFormData] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [showConfirmChanges, setShowConfirmChanges] = useState(false);
const {
data: curScriptContent,
error: isSelectedScriptContentError,
isLoading: isLoadingSelectedScriptContent,
} = useQuery<ScriptContent, Error>(
[scriptId],
() => scriptAPI.downloadScript(scriptId),
{
...DEFAULT_USE_QUERY_OPTIONS,
onSuccess: (curScriptContent_) => {
setScriptFormData(curScriptContent_);
},
}
);
const onChange = (value: string) => {
setScriptFormData(value);
const err = validate(value);
if (!err && !!formError) {
setFormError(validate(value));
}
};
const onBlur = () => {
setFormError(validate(scriptFormData));
};
const onSave = async () => {
const err = validate(scriptFormData);
setFormError(err);
if (err || isSubmitting) {
return;
}
// if contents have changed and not already showing the warning modal
if (curScriptContent !== scriptFormData && !showConfirmChanges) {
setShowConfirmChanges(true);
return;
}
// show warning modal and abort this call
try {
setIsSubmitting(true);
await scriptAPI.updateScript(scriptId, scriptFormData, scriptName);
renderFlash("success", "Successfully saved script.");
onExit();
} catch (e) {
renderFlash("error", getErrorMessage(e));
} finally {
setIsSubmitting(false);
setShowConfirmChanges(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." />;
}
// Set editing mode based on the file extension.
const mode = scriptName.match(/\.sh$/) ? "sh" : "powershell";
return (
<>
<form onSubmit={onSubmit}>
<Editor
mode={mode}
error={formError}
label="Script"
onBlur={onBlur}
onChange={onChange}
value={scriptFormData}
/>
<div className="form-field__help-text">
To run this script on a host, go to the{" "}
<CustomLink
text="Hosts"
url={getPathWithQueryParams(paths.MANAGE_HOSTS, {
team_id: currentTeam?.id,
})}
/>{" "}
page and select a host.
<br />
To run the script across multiple hosts, add a policy automation on
the{" "}
<CustomLink
text="Policies"
url={getPathWithQueryParams(paths.MANAGE_POLICIES, {
team_id: currentTeam?.id,
})}
/>{" "}
page.
</div>
</form>
<ModalFooter
primaryButtons={
<>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
<Button
onClick={onSave}
isLoading={isSubmitting}
disabled={!!formError}
>
Save
</Button>
</>
}
/>
</>
);
};
const classes = classnames(baseClass, {
[`${baseClass}__hide-main`]: !!showConfirmChanges,
});
return (
<>
<Modal
className={classes}
title={scriptName}
width="large"
onExit={onExit}
>
{renderContent()}
</Modal>
{!!showConfirmChanges && (
<WarningModal
onExit={() => setShowConfirmChanges(false)}
onSave={onSave}
scriptName={scriptName}
isSubmitting={isSubmitting}
/>
)}
</>
);
};
export default EditScriptModal;