From 576dcff1a7eec21ccac501cdeb9eb692e6b0a923 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:48:11 -0500 Subject: [PATCH] Fleet UI: Add script content modal to UI in various places (#23461) --- changes/22446-scripts-modal | 1 + .../ActionsDropdown/ActionsDropdown.tsx | 5 +- .../SoftwareInstallDetails/_styles.scss | 3 - frontend/components/ListItem/ListItem.tsx | 2 +- frontend/components/Modal/Modal.tsx | 6 +- frontend/components/Modal/_styles.scss | 47 +++- .../cards/ActivityFeed/ActivityFeed.tsx | 4 +- .../RunScriptDetailsModal.tsx} | 73 +++-- .../_styles.scss | 6 +- .../components/RunScriptDetailsModal/index.ts | 1 + .../ManageControlsPage/Scripts/Scripts.tsx | 42 ++- .../DeleteScriptModal/DeleteScriptModal.tsx | 3 + .../ScriptDetailsModal/ScriptDetailsModal.tsx | 254 ++++++++++++++++++ .../ScriptDetailsModal/_styles.scss | 13 + .../components/ScriptDetailsModal/index.ts | 0 .../ScriptListItem/ScriptListItem.tests.tsx | 90 +++++-- .../ScriptListItem/ScriptListItem.tsx | 39 +-- .../HostDetailsPage/HostDetailsPage.tsx | 48 ++-- .../modals/RunScriptModal/RunScriptModal.tsx | 80 +++--- .../RunScriptModal/ScriptsTableConfig.tsx | 41 ++- .../modals/RunScriptModal/_styles.scss | 2 + .../ScriptModalGroup/ScriptModalGroup.tsx | 200 ++++++++++++++ .../modals/ScriptModalGroup/index.ts | 1 + 23 files changed, 793 insertions(+), 168 deletions(-) create mode 100644 changes/22446-scripts-modal rename frontend/pages/DashboardPage/cards/ActivityFeed/components/{ScriptDetailsModal/ScriptDetailsModal.tsx => RunScriptDetailsModal/RunScriptDetailsModal.tsx} (80%) rename frontend/pages/DashboardPage/cards/ActivityFeed/components/{ScriptDetailsModal => RunScriptDetailsModal}/_styles.scss (93%) create mode 100644 frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/index.ts create mode 100644 frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx create mode 100644 frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/_styles.scss rename frontend/pages/{DashboardPage/cards/ActivityFeed => ManageControlsPage/Scripts}/components/ScriptDetailsModal/index.ts (100%) create mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/ScriptModalGroup.tsx create mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/index.ts diff --git a/changes/22446-scripts-modal b/changes/22446-scripts-modal new file mode 100644 index 0000000000..1e06aea931 --- /dev/null +++ b/changes/22446-scripts-modal @@ -0,0 +1 @@ +- Users can view scripts in the UI (from both the scripts page and host details page) without downloading them diff --git a/frontend/components/ActionsDropdown/ActionsDropdown.tsx b/frontend/components/ActionsDropdown/ActionsDropdown.tsx index 4e731250c9..0ac7c2d61c 100644 --- a/frontend/components/ActionsDropdown/ActionsDropdown.tsx +++ b/frontend/components/ActionsDropdown/ActionsDropdown.tsx @@ -25,6 +25,7 @@ interface IActionsDropdownProps { isSearchable?: boolean; className?: string; menuAlign?: "right" | "left" | "default"; + menuPlacement?: "top" | "bottom" | "auto"; } const getOptionBackgroundColor = (state: any) => { @@ -113,6 +114,7 @@ const ActionsDropdown = ({ isSearchable = false, className, menuAlign = "default", + menuPlacement = "bottom", }: IActionsDropdownProps): JSX.Element => { const dropdownClassnames = classnames(baseClass, className); @@ -178,7 +180,7 @@ const ActionsDropdown = ({ borderRadius: "4px", zIndex: 6, border: 0, - marginTop: 0, + margin: 0, width: "auto", minWidth: "100%", position: "absolute", @@ -239,6 +241,7 @@ const ActionsDropdown = ({ className={dropdownClassnames} classNamePrefix={`${baseClass}-select`} isOptionDisabled={(option) => !!option.disabled} + menuPlacement={menuPlacement} /> ); diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss index 8cdc2ef600..d9da1ab248 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss @@ -1,7 +1,4 @@ .software-install-details { - .modal__content { - margin-top: $pad-xlarge; - } &__status-message { display: flex; align-items: center; diff --git a/frontend/components/ListItem/ListItem.tsx b/frontend/components/ListItem/ListItem.tsx index 8b120bff33..24f90d2b56 100644 --- a/frontend/components/ListItem/ListItem.tsx +++ b/frontend/components/ListItem/ListItem.tsx @@ -27,7 +27,7 @@ export type ISupportedGraphicNames = Extract< interface IListItemProps { /** The grahpic you want to display for this list item. */ graphic: ISupportedGraphicNames; - title: string; + title: string | JSX.Element; details: React.ReactNode; /** A collection of React Nodes that will render as list item actions. Can be * used to render buttons, links, etc. diff --git a/frontend/components/Modal/Modal.tsx b/frontend/components/Modal/Modal.tsx index 8342002ac3..d3947370a2 100644 --- a/frontend/components/Modal/Modal.tsx +++ b/frontend/components/Modal/Modal.tsx @@ -37,6 +37,7 @@ export interface IModalProps { * */ disableClosingModal?: boolean; className?: string; + actionsFooter?: JSX.Element; } const Modal = ({ @@ -50,6 +51,7 @@ const Modal = ({ isContentDisabled = false, disableClosingModal = false, className, + actionsFooter, }: IModalProps): JSX.Element => { useEffect(() => { const closeWithEscapeKey = (e: KeyboardEvent) => { @@ -119,13 +121,15 @@ const Modal = ({ )} -
{isContentDisabled && (
)}
{children}
+ {actionsFooter && ( +
{actionsFooter}
+ )}
); diff --git a/frontend/components/Modal/_styles.scss b/frontend/components/Modal/_styles.scss index 04dc539176..e12ba66409 100644 --- a/frontend/components/Modal/_styles.scss +++ b/frontend/components/Modal/_styles.scss @@ -7,10 +7,16 @@ display: flex; justify-content: center; animation: fade-in 150ms ease-out; + visibility: visible; + opacity: 1; // Used for transitions between modals + transition: opacity 150ms ease-out, visibility 0s; // Used for transitions between modals } &__hidden { visibility: hidden; + opacity: 0; // Used for transitions between modals + transition: opacity 150ms ease-out, visibility 0s 150ms; // Used for transitions between modals + .fleet-checkbox__tick:after { visibility: hidden; } @@ -19,6 +25,8 @@ &__content-wrapper { margin-top: $pad-large; font-size: $x-small; + max-height: 800px; + overflow-y: auto; .input-field { width: 100%; @@ -35,14 +43,6 @@ // compensate for form gap to maintain consistent 32px spacing on top of modal ctas margin-top: $pad-small; } - - .modal-cta-wrap { - align-self: flex-end; - display: flex; - flex-direction: row-reverse; - margin-top: $pad-xlarge; - gap: $pad-medium; - } } &__ex { @@ -117,4 +117,35 @@ width: 100%; z-index: 1000; } + + &__actions-footer { + display: flex; + justify-content: space-between; + padding-top: $pad-medium; + + // Styles both primary-actions and secondary-actions + > * { + display: flex; + justify-content: space-between; + gap: $pad-medium; + align-items: center; + } + + // Align primary actions right if no secondary actions + > :last-child { + margin-left: auto; + } + + .button__text-icon { + padding: 11px; + } + } +} + +.modal-cta-wrap { + align-self: flex-end; + display: flex; + flex-direction: row-reverse; + margin-top: $pad-xlarge; + gap: $pad-medium; } diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index 47f08d4430..74cd4d4dbe 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -21,7 +21,7 @@ import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallD import SoftwareUninstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal"; import ActivityItem from "./ActivityItem"; -import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal"; +import RunScriptDetailsModal from "./components/RunScriptDetailsModal/RunScriptDetailsModal"; const baseClass = "activity-feed"; interface IActvityCardProps { @@ -202,7 +202,7 @@ const ActivityFeed = ({ /> )} {showScriptDetailsModal && ( - setShowScriptDetailsModal(false)} /> diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal/ScriptDetailsModal.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/RunScriptDetailsModal.tsx similarity index 80% rename from frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal/ScriptDetailsModal.tsx rename to frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/RunScriptDetailsModal.tsx index 3a741c884a..bbca7060f1 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal/ScriptDetailsModal.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/RunScriptDetailsModal.tsx @@ -11,7 +11,7 @@ import Textarea from "components/Textarea"; import DataError from "components/DataError/DataError"; import Spinner from "components/Spinner/Spinner"; -const baseClass = "script-details-modal"; +const baseClass = "run-script-details-modal"; interface IScriptContentProps { content: string; @@ -144,52 +144,34 @@ const ScriptOutput = ({ output, hostname }: IScriptOutputProps) => { interface IScriptResultProps { hostname: string; - hostTimeout: boolean; - exitCode: number | null; - message: string; output: string; } -const ScriptResult = ({ - hostname, - hostTimeout, - exitCode, - message, - output, -}: IScriptResultProps) => { - const hostTimedOut = exitCode === null && hostTimeout === true; - const scriptsDisabledForHost = exitCode === -2; - const scriptStillRunning = exitCode === null && hostTimeout === false; - const showOutputText = - !hostTimedOut && !scriptsDisabledForHost && !scriptStillRunning; - +const ScriptResult = ({ hostname, output }: IScriptResultProps) => { return (
- - {showOutputText && } +
); }; -interface IScriptDetailsModalProps { +interface IRunScriptDetailsModalProps { scriptExecutionId: string; onCancel: () => void; + isHidden?: boolean; } -const ScriptDetailsModal = ({ +const RunScriptDetailsModal = ({ scriptExecutionId, onCancel, -}: IScriptDetailsModalProps) => { + isHidden = false, +}: IRunScriptDetailsModalProps) => { const { data, isLoading, isError } = useQuery( - ["scriptDetailsModal", scriptExecutionId], + ["runScriptDetailsModal", scriptExecutionId], () => { return scriptsAPI.getScriptResult(scriptExecutionId); }, - { refetchOnWindowFocus: false } + { refetchOnWindowFocus: false, enabled: !!scriptExecutionId } ); const renderContent = () => { @@ -200,16 +182,25 @@ const ScriptDetailsModal = ({ } else if (isError) { content = ; } else if (data) { + const hostTimedOut = + data.exit_code === null && data.host_timeout === true; + const scriptsDisabledForHost = data.exit_code === -2; + const scriptStillRunning = + data.exit_code === null && data.host_timeout === false; + const showOutputText = + !hostTimedOut && !scriptsDisabledForHost && !scriptStillRunning; + content = ( <> - - + + {showOutputText && ( + + )} ); } @@ -217,25 +208,29 @@ const ScriptDetailsModal = ({ return ( <>
{content}
-
- -
); }; + const renderFooter = () => ( +
+ +
+ ); return ( {renderContent()} ); }; -export default ScriptDetailsModal; +export default RunScriptDetailsModal; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal/_styles.scss b/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/_styles.scss similarity index 93% rename from frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal/_styles.scss rename to frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/_styles.scss index b7a7e1e106..307adc6746 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal/_styles.scss +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/_styles.scss @@ -1,11 +1,11 @@ -.script-details-modal { +.run-script-details-modal { box-sizing: border-box; width: 811px; &__modal-content { display: flex; flex-direction: column; - gap: $pad-xlarge; + gap: $pad-medium; } &__script-content { @@ -16,7 +16,7 @@ &__script-content-textarea { box-sizing: border-box; - height: 300px; + max-height: 300px; overflow-y: auto; font-family: "SourceCodePro"; } diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/index.ts b/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/index.ts new file mode 100644 index 0000000000..35c3b158ca --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./RunScriptDetailsModal"; diff --git a/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx b/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx index b7dd31d08f..98d9061514 100644 --- a/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx @@ -19,6 +19,7 @@ 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"; @@ -34,6 +35,8 @@ interface IScriptsProps { const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => { const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false); + const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false); + const [goBackToScriptDetails, setGoBackToScriptDetails] = useState(false); // Used for onCancel in delete modal const selectedScript = useRef(null); @@ -77,14 +80,30 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => { const { config } = useContext(AppContext); if (!config) return null; + const onClickScript = (script: IScript) => { + selectedScript.current = script; + setShowScriptDetailsModal(true); + }; + + const onCancelScriptDetails = () => { + selectedScript.current = null; + setShowScriptDetailsModal(false); + setGoBackToScriptDetails(false); + }; + const onClickDelete = (script: IScript) => { selectedScript.current = script; setShowDeleteScriptModal(true); }; const onCancelDelete = () => { - selectedScript.current = null; setShowDeleteScriptModal(false); + + if (goBackToScriptDetails) { + setShowScriptDetailsModal(true); + } else { + selectedScript.current = null; + } }; const onDeleteScript = () => { @@ -117,7 +136,11 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => { listItems={scripts || []} HeadingComponent={ScriptListHeading} ListItemComponent={({ listItem }) => ( - + )} /> { onDone={onDeleteScript} /> )} + {showScriptDetailsModal && selectedScript.current && ( + { + setShowScriptDetailsModal(false); + setShowDeleteScriptModal(true); + setGoBackToScriptDetails(true); + }} + runScriptHelpText + /> + )} ); }; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx index b2ca6622d4..001a869c93 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx @@ -16,6 +16,7 @@ interface IDeleteScriptModalProps { scriptId: number; onCancel: () => void; onDone: () => void; + isHidden?: boolean; } const DeleteScriptModal = ({ @@ -23,6 +24,7 @@ const DeleteScriptModal = ({ scriptId, onCancel, onDone, + isHidden = false, }: IDeleteScriptModalProps) => { const { renderFlash } = useContext(NotificationContext); @@ -49,6 +51,7 @@ const DeleteScriptModal = ({ title="Delete script" onExit={onCancel} onEnter={() => onClickDelete(scriptId)} + isHidden={isHidden} > <>

diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx new file mode 100644 index 0000000000..bdbbd086c9 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx @@ -0,0 +1,254 @@ +import React, { useCallback, useContext } from "react"; +import { format } from "date-fns"; +import { useQuery } from "react-query"; +import FileSaver from "file-saver"; + +import { AppContext } from "context/app"; +import { NotificationContext } from "context/notification"; +import scriptAPI from "services/entities/scripts"; +import { IHostScript } from "interfaces/script"; +import { getErrorReason } from "interfaces/errors"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import Spinner from "components/Spinner"; +import Icon from "components/Icon"; +import Textarea from "components/Textarea"; +import CustomLink from "components/CustomLink"; +import DataError from "components/DataError"; +import paths from "router/paths"; +import ActionsDropdown from "components/ActionsDropdown"; +import { generateActionDropdownOptions } from "pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig"; + +const baseClass = "script-details-modal"; + +type PartialOrFullHostScript = + | Pick // Use on Scripts page does not include last_execution + | IHostScript; + +interface IScriptDetailsModalProps { + onCancel: () => void; + onDelete: () => void; + runScriptHelpText?: boolean; + showHostScriptActions?: boolean; + setRunScriptRequested?: (value: boolean) => void; + hostId?: number | null; + hostTeamId?: number | null; + refetchHostScripts?: any; + selectedScriptDetails?: PartialOrFullHostScript; + selectedScriptContent?: string; + isLoadingScriptContent?: boolean; + isScriptContentError?: Error | null; + isHidden?: boolean; + onCloseScriptModalGroup?: () => void; + onClickRunDetails?: (scriptExecutionId: string) => void; +} + +const ScriptDetailsModal = ({ + onCancel, + onDelete, + runScriptHelpText = false, + showHostScriptActions = false, + setRunScriptRequested, + hostId, + hostTeamId, + refetchHostScripts, + selectedScriptDetails, + selectedScriptContent, + isLoadingScriptContent, + isScriptContentError, + isHidden = false, + onCloseScriptModalGroup, + onClickRunDetails, +}: IScriptDetailsModalProps) => { + const { currentUser } = useContext(AppContext); + const { renderFlash } = useContext(NotificationContext); + + const { + data: scriptContent, + error: isSelectedScriptContentError, + isLoading: isLoadingSelectedScriptContent, + } = useQuery( + ["scriptContent", selectedScriptDetails?.script_id], + () => + selectedScriptDetails + ? scriptAPI.downloadScript(selectedScriptDetails.script_id!) + : Promise.resolve(null), + { + refetchOnWindowFocus: false, + enabled: !selectedScriptContent && !!selectedScriptDetails?.script_id, + } + ); + const getScriptContent = async () => { + try { + const content = selectedScriptContent || scriptContent; + const formatDate = format(new Date(), "yyyy-MM-dd"); + const filename = `${formatDate} ${ + selectedScriptDetails?.name || "Script details" + }`; + const file = new File([content], filename); + FileSaver.saveAs(file); + } catch { + renderFlash("error", "Couldn’t Download. Please try again."); + } + }; + + const onClickDownload = () => { + if (selectedScriptContent) { + const formatDate = format(new Date(), "yyyy-MM-dd"); + const filename = `${formatDate} ${selectedScriptDetails?.name}`; + const file = new File([selectedScriptContent], filename); + FileSaver.saveAs(file); + } else { + getScriptContent(); + } + }; + + const onSelectMoreActions = useCallback( + async (action: string, script: IHostScript) => { + if ( + hostId && + setRunScriptRequested && + refetchHostScripts && + script.last_execution?.execution_id + ) { + switch (action) { + case "showRunDetails": { + onClickRunDetails && + onClickRunDetails(script.last_execution?.execution_id); + break; + } + case "run": { + try { + setRunScriptRequested && setRunScriptRequested(true); + await scriptAPI.runScript({ + host_id: hostId, + script_id: script.script_id, + }); + renderFlash( + "success", + "Script is running or will run when the host comes online." + ); + refetchHostScripts(); + onCloseScriptModalGroup && onCloseScriptModalGroup(); // Running a script closes the modal groups + } catch (e) { + renderFlash("error", getErrorReason(e)); + setRunScriptRequested(false); + } + break; + } + default: // do nothing + } + } + }, + [ + hostId, + onClickRunDetails, + setRunScriptRequested, + refetchHostScripts, + onCloseScriptModalGroup, + renderFlash, + ] + ); + + const shouldShowFooter = () => { + return !isLoadingScriptContent && selectedScriptDetails !== undefined; + }; + + const renderFooter = () => { + if (!shouldShowFooter) { + return <>; + } + + return ( + <> +

+ + +
+
+ {showHostScriptActions && selectedScriptDetails && ( +
+ + onSelectMoreActions( + value, + selectedScriptDetails as IHostScript + ) + } + placeholder="More actions" + isSearchable={false} + options={generateActionDropdownOptions( + currentUser, + hostTeamId || null, + selectedScriptDetails as IHostScript + )} + menuPlacement="top" + /> +
+ )} + +
+ + ); + }; + + const renderContent = () => { + if (isLoadingScriptContent || isLoadingSelectedScriptContent) { + return ; + } + + if (isScriptContentError || isSelectedScriptContentError) { + return ; + } + + return ( +
+ Script content: + + {runScriptHelpText && ( +
+ 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 ScriptDetailsModal; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/_styles.scss b/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/_styles.scss new file mode 100644 index 0000000000..817ae074d1 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/_styles.scss @@ -0,0 +1,13 @@ +.script-details-modal { + &__script-content { + display: flex; + flex-direction: column; + gap: $pad-medium; + } + + &__script-content-textarea { + font-family: "SourceCodePro"; + max-height: 572px; + overflow: auto; + } +} diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal/index.ts b/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/index.ts similarity index 100% rename from frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal/index.ts rename to frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/index.ts diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tests.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tests.tsx index af59c15070..2e2d1ff346 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tests.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tests.tsx @@ -1,37 +1,79 @@ import React from "react"; - -import { render, screen } from "@testing-library/react"; - +import { render, screen, fireEvent } from "@testing-library/react"; import { IScript } from "interfaces/script"; import ScriptListItem from "./ScriptListItem"; +const MAC_SCRIPT: IScript = { + id: 1, + team_id: 1, + name: "test_mac_script.sh", + created_at: "2021-01-01", + updated_at: "2021-01-01", +}; + +const WINDOWS_SCRIPT: IScript = { + id: 1, + team_id: 1, + name: "test_win_script.ps1", + created_at: "2021-01-01", + updated_at: "2021-01-01", +}; + describe("ScriptListItem", () => { - const onDelete = (script: IScript) => jest.fn(); - it("Renders a Script list item with correct graphic and platform for macOS", () => { - const script: IScript = { - id: 1, - team_id: 1, - name: "test_mac_script.sh", - created_at: "2021-01-01", - updated_at: "2021-01-01", - }; - render(); + const onDelete = jest.fn(); + const onClickScript = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("Renders a Script list item with correct platform for macOS", () => { + render( + + ); expect(screen.getByText(/macOS & Linux/)).toBeInTheDocument(); - expect(screen.queryByTestId("file-sh-graphic")).toBeInTheDocument(); }); - it("Renders a Script list item with correct graphic and platform for Windows", () => { - const script: IScript = { - id: 1, - team_id: 1, - name: "test_win_script.ps1", - created_at: "2021-01-01", - updated_at: "2021-01-01", - }; - render(); + it("Renders a Script list item with correct platform for Windows", () => { + render( + + ); expect(screen.getByText(/Windows/)).toBeInTheDocument(); - expect(screen.queryByTestId("file-ps1-graphic")).toBeInTheDocument(); + }); + + it("calls onClickScript when script name is clicked", () => { + render( + + ); + + fireEvent.click(screen.getByText("test_mac_script.sh")); + expect(onClickScript).toHaveBeenCalledWith(MAC_SCRIPT); + }); + + it("calls onDelete when delete button is clicked", () => { + render( + + ); + + fireEvent.click(screen.getByTestId("trash-icon")); + expect(onDelete).toHaveBeenCalledWith(MAC_SCRIPT); }); }); diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx index 6b2fbcede8..1f077099db 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx @@ -16,6 +16,7 @@ const baseClass = "script-list-item"; interface IScriptListItemProps { script: IScript; onDelete: (script: IScript) => void; + onClickScript: (script: IScript) => void; } // TODO - useful to have a 'platform' field from API, for use elsewhere in app as well? @@ -41,6 +42,18 @@ interface IScriptListItemDetailsProps { createdAt: string; } +const onClickDownload = async (script: IScript, renderFlash: any) => { + try { + const content = await scriptAPI.downloadScript(script.id); + const formatDate = format(new Date(), "yyyy-MM-dd"); + const filename = `${formatDate} ${script.name}`; + const file = new File([content], filename); + FileSaver.saveAs(file); + } catch { + renderFlash("error", "Couldn’t Download. Please try again."); + } +}; + const ScriptListItemDetails = ({ platform, createdAt, @@ -56,28 +69,24 @@ const ScriptListItemDetails = ({ ); -const ScriptListItem = ({ script, onDelete }: IScriptListItemProps) => { +const ScriptListItem = ({ + script, + onDelete, + onClickScript, +}: IScriptListItemProps) => { const { renderFlash } = useContext(NotificationContext); - const onClickDownload = async () => { - try { - const content = await scriptAPI.downloadScript(script.id); - const formatDate = format(new Date(), "yyyy-MM-dd"); - const filename = `${formatDate} ${script.name}`; - const file = new File([content], filename); - FileSaver.saveAs(file); - } catch { - renderFlash("error", "Couldn’t Download. Please try again."); - } - }; - const { graphicName, platform } = getFileRenderDetails(script.name); return ( onClickScript(script)}> + {script.name} + + } details={ { diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index aa553f116e..c4b98b5cfb 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -39,6 +39,7 @@ import { IQueryKeyQueriesLoadAll, ISchedulableQuery, } from "interfaces/schedulable_query"; +import { IHostScript } from "interfaces/script"; import { normalizeEmptyValues, @@ -59,7 +60,7 @@ import Spinner from "components/Spinner"; import TabsWrapper from "components/TabsWrapper"; import MainContent from "components/MainContent"; import BackLink from "components/BackLink"; -import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal"; +import RunScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal"; import { AppInstallDetailsModal, IAppInstallDetails, @@ -90,7 +91,7 @@ import DiskEncryptionKeyModal from "./modals/DiskEncryptionKeyModal"; import HostActionsDropdown from "./HostActionsDropdown/HostActionsDropdown"; import OSSettingsModal from "../OSSettingsModal"; import BootstrapPackageModal from "./modals/BootstrapPackageModal"; -import RunScriptModal from "./modals/RunScriptModal"; +import ScriptModalGroup from "./modals/ScriptModalGroup"; import SelectQueryModal from "./modals/SelectQueryModal"; import { isSupportedPlatform } from "./modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal"; import HostDetailsBanners from "./components/HostDetailsBanners"; @@ -164,7 +165,7 @@ const HostDetailsPage = ({ const [showDeleteHostModal, setShowDeleteHostModal] = useState(false); const [showTransferHostModal, setShowTransferHostModal] = useState(false); const [showSelectQueryModal, setShowSelectQueryModal] = useState(false); - const [showRunScriptModal, setShowRunScriptModal] = useState(false); + const [showScriptModalGroup, setShowScriptModalGroup] = useState(false); const [showPolicyDetailsModal, setPolicyDetailsModal] = useState(false); const [showOSSettingsModal, setShowOSSettingsModal] = useState(false); const [showUnenrollMdmModal, setShowUnenrollMdmModal] = useState(false); @@ -175,7 +176,8 @@ const HostDetailsPage = ({ const [showLockHostModal, setShowLockHostModal] = useState(false); const [showUnlockHostModal, setShowUnlockHostModal] = useState(false); const [showWipeModal, setShowWipeModal] = useState(false); - const [scriptDetailsId, setScriptDetailsId] = useState(""); + // Used in activities to show run script details modal + const [scriptExecutionId, setScriptExecutiontId] = useState(""); const [selectedPolicy, setSelectedPolicy] = useState( null ); @@ -595,7 +597,7 @@ const HostDetailsPage = ({ ({ type, details }: IShowActivityDetailsData) => { switch (type) { case "ran_script": - setScriptDetailsId(details?.script_execution_id || ""); + setScriptExecutiontId(details?.script_execution_id || ""); break; case "installed_software": setPackageInstallDetails({ @@ -652,8 +654,8 @@ const HostDetailsPage = ({ ); }; - const onCancelScriptDetailsModal = useCallback(() => { - setScriptDetailsId(""); + const onCancelRunScriptDetailsModal = useCallback(() => { + setScriptExecutiontId(""); // refetch activities to make sure they up-to-date with what was displayed in the modal refetchPastActivities(); refetchUpcomingActivities(); @@ -699,8 +701,8 @@ const HostDetailsPage = ({ [] ); - const onCloseRunScriptModal = useCallback(() => { - setShowRunScriptModal(false); + const onCloseScriptModalGroup = useCallback(() => { + setShowScriptModalGroup(false); refetchPastActivities(); refetchUpcomingActivities(); }, [refetchPastActivities, refetchUpcomingActivities]); @@ -723,7 +725,7 @@ const HostDetailsPage = ({ setShowDeleteHostModal(true); break; case "runScript": - setShowRunScriptModal(true); + setShowScriptModalGroup(true); break; case "lock": setShowLockHostModal(true); @@ -1007,17 +1009,13 @@ const HostDetailsPage = ({ hostsTeamId={host?.team_id} /> )} - {showRunScriptModal && - // force run script modal to unmount when script details modal is shown; - // it will be remounted when script details modal is closed - !scriptDetailsId && ( - - )} + {showScriptModalGroup && ( + + )} {!!host && showTransferHostModal && ( setShowTransferHostModal(false)} @@ -1064,10 +1062,10 @@ const HostDetailsPage = ({ onClose={() => setShowBootstrapPackageModal(false)} /> )} - {!!scriptDetailsId && ( - )} {!!packageInstallDetails && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx index 5df4dda24d..6206207c3b 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx @@ -1,18 +1,14 @@ -import React, { useCallback, useContext, useMemo, useState } from "react"; -import { useQuery } from "react-query"; +import React, { useCallback, useContext, useMemo } from "react"; import { AppContext } from "context/app"; import { NotificationContext } from "context/notification"; -import { getErrorReason, IApiError } from "interfaces/errors"; +import { getErrorReason } from "interfaces/errors"; import { IHost } from "interfaces/host"; import { IHostScript } from "interfaces/script"; import { IUser } from "interfaces/user"; -import scriptsAPI, { - IHostScriptsQueryKey, - IHostScriptsResponse, -} from "services/entities/scripts"; +import scriptsAPI, { IHostScriptsResponse } from "services/entities/scripts"; import Button from "components/buttons/Button"; import DataError from "components/DataError/DataError"; @@ -31,8 +27,19 @@ const baseClass = "run-script-modal"; interface IScriptsProps { currentUser: IUser | null; host: IHost; - setScriptDetailsId: React.Dispatch>; onClose: () => void; + runScriptRequested: boolean; + refetchHostScripts: () => void; + page: number; + setPage: React.Dispatch>; + hostScriptResponse?: IHostScriptsResponse; + isFetching: boolean; + isLoading: boolean; + isError: boolean; + setRunScriptRequested: React.Dispatch>; + onClickViewScript: (scriptId: number, scriptDetails: IHostScript) => void; + onClickRunDetails: (scriptExecutionId: string) => void; + isHidden: boolean; } const EmptyComponent = () => <>; @@ -40,44 +47,29 @@ const EmptyComponent = () => <>; const RunScriptModal = ({ currentUser, host, - setScriptDetailsId, onClose, + runScriptRequested, + refetchHostScripts, + page, + setPage, + setRunScriptRequested, + hostScriptResponse, + isFetching, + isLoading, + isError, + onClickViewScript, + onClickRunDetails, + isHidden = false, }: IScriptsProps) => { - const [page, setPage] = useState(0); - const [runScriptRequested, setRunScriptRequested] = useState(false); - const { renderFlash } = useContext(NotificationContext); const { config } = useContext(AppContext); - const { - data: hostScriptResponse, - isError, - isLoading, - isFetching, - refetch: refetchHostScripts, - } = useQuery< - IHostScriptsResponse, - IApiError, - IHostScriptsResponse, - IHostScriptsQueryKey[] - >( - [{ scope: "host_scripts", host_id: host.id, page, per_page: 10 }], - ({ queryKey }) => scriptsAPI.getHostScripts(queryKey[0]), - { - refetchOnWindowFocus: false, - retry: false, - staleTime: 3000, - onSuccess: () => { - setRunScriptRequested(false); - }, - } - ); - const onSelectAction = useCallback( async (action: string, script: IHostScript) => { switch (action) { - case "showDetails": { - setScriptDetailsId(script.last_execution?.execution_id || ""); + case "showRunDetails": { + script.last_execution?.execution_id && + onClickRunDetails(script.last_execution?.execution_id); break; } case "run": { @@ -101,7 +93,13 @@ const RunScriptModal = ({ default: // do nothing } }, - [host.id, refetchHostScripts, renderFlash, setScriptDetailsId] + [ + host.id, + onClickRunDetails, + refetchHostScripts, + renderFlash, + setRunScriptRequested, + ] ); const onQueryChange = useCallback(({ pageIndex }: ITableQueryData) => { @@ -114,9 +112,10 @@ const RunScriptModal = ({ currentUser, host.team_id, !!config?.server_settings?.scripts_disabled, + onClickViewScript, onSelectAction ), - [currentUser, host.team_id, config, onSelectAction] + [currentUser, host.team_id, config, onClickViewScript, onSelectAction] ); if (!config) return null; @@ -130,6 +129,7 @@ const RunScriptModal = ({ onEnter={onClose} className={`${baseClass}`} isLoading={runScriptRequested || isFetching || isLoading} + isHidden={isHidden} > <>
diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig.tsx index a8ecb5e368..69ade11fc0 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig.tsx @@ -14,10 +14,22 @@ import { isGlobalObserver, isTeamObserver, } from "utilities/permissions/permissions"; +import Button from "components/buttons/Button"; import TooltipWrapper from "components/TooltipWrapper"; import ScriptStatusCell from "./components/ScriptStatusCell"; +interface IRowProps { + row: { + original: IHostScript; + }; +} +interface ICellProps extends IRowProps { + cell: { + value: string; + }; +} + interface IStatusCellProps { cell: { value: ILastExecution | null; @@ -33,7 +45,7 @@ interface IActionsDropdownProps { }; } -const generateActionDropdownOptions = ( +export const generateActionDropdownOptions = ( currentUser: IUser | null, teamId: number | null, { last_execution }: IHostScript @@ -51,13 +63,13 @@ const generateActionDropdownOptions = ( isTeamObserver(currentUser, teamId)); const options: IDropdownOption[] = [ { - label: "Show details", + label: "Show run details", disabled: last_execution === null, - value: "showDetails", + value: "showRunDetails", }, ]; hasRunPermission && - options.push({ + options.unshift({ label: "Run", disabled: isPending, value: "run", @@ -71,6 +83,7 @@ export const generateTableColumnConfigs = ( currentUser: IUser | null, hostTeamId: number | null, scriptsDisabled: boolean, + onClickViewScript: (scriptId: number, scriptDetails: IHostScript) => void, onSelectAction: (value: string, script: IHostScript) => void ) => { return [ @@ -79,6 +92,25 @@ export const generateTableColumnConfigs = ( Header: "Name", disableSortBy: true, accessor: "name", + Cell: (cellProps: ICellProps) => { + const { name, script_id } = cellProps.row.original; + + const onClickScriptName = (e: React.MouseEvent) => { + // Allows for button to be clickable in a clickable row + e.stopPropagation(); + onClickViewScript(script_id, cellProps.row.original); + }; + + return ( + + ); + }, }, { title: "Status", @@ -127,6 +159,7 @@ export const generateTableColumnConfigs = ( } placeholder="Actions" disabled={scriptsDisabled} + menuAlign="right" /> ); }, diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/_styles.scss index 89c4c4213b..47eb7ab5a1 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/_styles.scss @@ -6,6 +6,8 @@ display: flex; flex-direction: column; gap: $pad-xlarge; + width: 729px; // Fixes cut off table border + margin-bottom: 9px; // Fixes cut off dropdown .empty-table__container { max-width: none; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/ScriptModalGroup.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/ScriptModalGroup.tsx new file mode 100644 index 0000000000..1d9268e8d6 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/ScriptModalGroup.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useState } from "react"; +import { useQuery } from "react-query"; + +import { IApiError } from "interfaces/errors"; +import { IHost } from "interfaces/host"; +import { IHostScript } from "interfaces/script"; +import { IUser } from "interfaces/user"; + +import scriptsAPI, { + IHostScriptsQueryKey, + IHostScriptsResponse, +} from "services/entities/scripts"; + +import ScriptDetailsModal from "pages/ManageControlsPage/Scripts/components/ScriptDetailsModal"; +import DeleteScriptModal from "pages/ManageControlsPage/Scripts/components/DeleteScriptModal"; +import RunScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal"; +import RunScriptModal from "../RunScriptModal"; + +interface IScriptsProps { + currentUser: IUser | null; + host: IHost; + onCloseScriptModalGroup: () => void; +} + +type ScriptGroupModals = + | "run-script" + | "view-script" + | "run-script-details" + | "delete-script" + | null; + +const ScriptModalGroup = ({ + currentUser, + host, + onCloseScriptModalGroup, +}: IScriptsProps) => { + const [previousModal, setPreviousModal] = useState(null); + const [currentModal, setCurrentModal] = useState( + "run-script" + ); + const [runScriptTablePage, setRunScriptTablePage] = useState(0); + const [selectedScriptId, setSelectedScriptId] = useState( + undefined + ); + const [selectedExecutionId, setSelectedExecutionId] = useState< + string | undefined + >(undefined); + const [selectedScriptDetails, setSelectedScriptDetails] = useState< + IHostScript | undefined + >(undefined); + // This sets a loading state of the Run script modal during a run request + const [runScriptRequested, setRunScriptRequested] = useState(false); + + // Almost everything from this is needed on RunScript.tsx modal + // except refetch is used multiple places + const { + data: runScriptTableResponse, + isError, + isLoading, + isFetching, + refetch: refetchHostScripts, + } = useQuery< + IHostScriptsResponse, + IApiError, + IHostScriptsResponse, + IHostScriptsQueryKey[] + >( + [ + { + scope: "host_scripts", + host_id: host.id, + page: runScriptTablePage, + per_page: 10, + }, + ], + ({ queryKey }) => scriptsAPI.getHostScripts(queryKey[0]), + { + refetchOnWindowFocus: false, + retry: false, + staleTime: 3000, + onSuccess: () => { + setRunScriptRequested(false); + }, + } + ); + + // Note: Script metadata and script content require two separate API calls + // Source: https://fleetdm.com/docs/rest-api/rest-api#example-get-script + // So to get script name, we pass it into this modal instead of another API call + // If in future iterations we want more script metadata, call scriptAPI.getScript() + // and consider refactoring .getScript to return script content as well + const { + data: selectedScriptContent, + error: isSelectedScriptContentError, + isLoading: isLoadingSelectedScriptContent, + } = useQuery( + ["scriptContent", selectedScriptId], + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + () => scriptsAPI.downloadScript(selectedScriptId!), + { + refetchOnWindowFocus: false, + enabled: !!selectedScriptId, + } + ); + + // Anytime a script runs, return back to Run script modal + useEffect(() => { + if (runScriptRequested) { + setCurrentModal("run-script"); + } + }, [runScriptRequested]); + + return ( + <> + { + setPreviousModal("run-script"); + setCurrentModal("view-script"); + setSelectedScriptId(scriptId); + setSelectedScriptDetails(scriptDetails); + }} + onClickRunDetails={(scriptExecutionId: string) => { + setPreviousModal("run-script"); + setCurrentModal("run-script-details"); + scriptExecutionId && setSelectedExecutionId(scriptExecutionId); + }} + runScriptRequested={runScriptRequested} + refetchHostScripts={refetchHostScripts} + page={runScriptTablePage} + setPage={setRunScriptTablePage} + hostScriptResponse={runScriptTableResponse} + isFetching={isFetching} + isLoading={isLoading} + isError={isError} + setRunScriptRequested={setRunScriptRequested} + isHidden={currentModal !== "run-script"} + /> + + { + setCurrentModal("run-script"); + setPreviousModal(null); + }} + onDelete={() => { + setCurrentModal("delete-script"); + setPreviousModal("view-script"); + }} + onClickRunDetails={(scriptExecutionId: string) => { + setCurrentModal("run-script-details"); + setPreviousModal("view-script"); + scriptExecutionId && setSelectedExecutionId(scriptExecutionId); + }} + setRunScriptRequested={setRunScriptRequested} + refetchHostScripts={refetchHostScripts} + isLoadingScriptContent={isLoadingSelectedScriptContent} + isScriptContentError={isSelectedScriptContentError} + isHidden={currentModal !== "view-script"} + showHostScriptActions + /> + { + setCurrentModal(previousModal); + setPreviousModal("run-script"); + }} + onDone={() => { + // The delete API call is handled in DeleteScriptModal + setCurrentModal(previousModal); + setPreviousModal("run-script"); + refetchHostScripts(); + setSelectedScriptDetails(undefined); + }} + isHidden={currentModal !== "delete-script"} + /> + { + if (previousModal === "view-script") { + setCurrentModal(previousModal); + setPreviousModal("run-script"); + } else if (previousModal === "run-script") { + setCurrentModal(previousModal); + setPreviousModal(null); + } + }} + isHidden={currentModal !== "run-script-details"} + /> + + ); +}; + +export default ScriptModalGroup; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/index.ts b/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/index.ts new file mode 100644 index 0000000000..550888969d --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/index.ts @@ -0,0 +1 @@ +export { default } from "./ScriptModalGroup";