diff --git a/src/@types/Instance.ts b/src/@types/Instance.ts index 2e591f67..d1b9279e 100644 --- a/src/@types/Instance.ts +++ b/src/@types/Instance.ts @@ -51,11 +51,49 @@ export type InstanceBase = { id: string; } & Partial; +export type UserScriptPhase = + | "osmorphing_pre_os_mount" + | "osmorphing_post_os_mount" + | "replica_first_boot"; + +export const USER_SCRIPT_PHASES: UserScriptPhase[] = [ + "osmorphing_pre_os_mount", + "osmorphing_post_os_mount", + "replica_first_boot", +]; + +export const DEFAULT_USER_SCRIPT_PHASE: UserScriptPhase = + "osmorphing_post_os_mount"; + +export const USER_SCRIPT_PHASE_OPTIONS: { + label: string; + value: UserScriptPhase; +}[] = [ + { label: "OS morphing: before mount", value: "osmorphing_pre_os_mount" }, + { label: "OS morphing: after mount", value: "osmorphing_post_os_mount" }, + { label: "VM first boot script", value: "replica_first_boot" }, +]; + +export const USER_SCRIPT_PHASE_DESCRIPTIONS: Record = { + osmorphing_pre_os_mount: + "Runs before the OS partition is mounted during OS morphing, e.g. to unlock encrypted disks.", + osmorphing_post_os_mount: + "Runs after the OS partition is mounted during OS morphing (the default).", + replica_first_boot: + "Injected during OS morphing and executed when the VM boots for the first time.", +}; + export type InstanceScript = { global?: "windows" | "linux" | null; instanceId?: string | null; scriptContent: string | null; fileName: string | null; + phase?: UserScriptPhase; +}; + +export type UserScriptTarget = { + global: "windows" | "linux" | null; + instanceId: string | null; }; export const InstanceUtils = { diff --git a/src/@types/MainItem.ts b/src/@types/MainItem.ts index 66e8696d..9d7cc5f5 100644 --- a/src/@types/MainItem.ts +++ b/src/@types/MainItem.ts @@ -13,7 +13,7 @@ along with this program. If not, see . */ import type { Execution } from "./Execution"; -import type { Instance, InstanceScript } from "./Instance"; +import type { Instance, InstanceScript, UserScriptPhase } from "./Instance"; import type { NetworkMap } from "./Network"; import type { StorageMap } from "./Endpoint"; import { Task } from "./Task"; @@ -100,13 +100,20 @@ export type TransferItem = BaseItem & { scenario: string; }; +export type UserScriptItem = { + phase: UserScriptPhase; + payload: string; +}; + +export type UserScriptValue = string | UserScriptItem[] | null; + export type UserScriptData = { global?: { - linux?: string | null; - windows?: string | null; + linux?: UserScriptValue; + windows?: UserScriptValue; }; instances?: { - [instanceName: string]: string | null; + [instanceName: string]: UserScriptValue; }; }; diff --git a/src/components/modules/TransferModule/DeploymentOptions/DeploymentOptions.tsx b/src/components/modules/TransferModule/DeploymentOptions/DeploymentOptions.tsx index e203a16c..761d4ef9 100644 --- a/src/components/modules/TransferModule/DeploymentOptions/DeploymentOptions.tsx +++ b/src/components/modules/TransferModule/DeploymentOptions/DeploymentOptions.tsx @@ -32,7 +32,12 @@ import deploymentImage from "./images/deployment.svg"; import deploymentFields from "./DeploymentFields"; import type { Field } from "@src/@types/Field"; -import type { Instance, InstanceScript } from "@src/@types/Instance"; +import type { + Instance, + InstanceScript, + UserScriptTarget, +} from "@src/@types/Instance"; +import { applyUserScriptsChange } from "@src/utils/UserScriptUtils"; const Wrapper = styled.div` display: flex; flex-direction: column; @@ -171,24 +176,14 @@ class DeploymentOptions extends React.Component { }); } - handleCancelScript(global: string | null, instanceName: string | null) { - this.setState(prevState => ({ - uploadedScripts: prevState.uploadedScripts.filter(s => - global ? s.global !== global : s.instanceId !== instanceName, - ), - })); - } - - handleScriptUpload(script: InstanceScript) { - this.setState(prevState => ({ - uploadedScripts: [...prevState.uploadedScripts, script], - })); - } - - handleScriptRemove(script: InstanceScript) { - this.setState(prevState => ({ - removedScripts: [...prevState.removedScripts, script], - })); + handleScriptsChange( + target: UserScriptTarget, + scripts: InstanceScript[], + hadExisting: boolean, + ) { + this.setState(prevState => + applyUserScriptsChange(prevState, target, scripts, hadExisting), + ); } renderField(field: Field) { @@ -270,14 +265,8 @@ class DeploymentOptions extends React.Component { { - this.handleScriptUpload(s); - }} - onScriptDataRemove={s => { - this.handleScriptRemove(s); - }} - onCancelScript={(g, i) => { - this.handleCancelScript(g, i); + onScriptsChange={(target, scripts, hadExisting) => { + this.handleScriptsChange(target, scripts, hadExisting); }} uploadedScripts={this.state.uploadedScripts} removedScripts={this.state.removedScripts} diff --git a/src/components/modules/TransferModule/DeploymentOptions/ReplicaDeploymentOptions.spec.tsx b/src/components/modules/TransferModule/DeploymentOptions/ReplicaDeploymentOptions.spec.tsx index d88a2bae..e77e8d57 100644 --- a/src/components/modules/TransferModule/DeploymentOptions/ReplicaDeploymentOptions.spec.tsx +++ b/src/components/modules/TransferModule/DeploymentOptions/ReplicaDeploymentOptions.spec.tsx @@ -34,16 +34,24 @@ jest.mock("@src/components/modules/WizardModule/WizardScripts", () => ({
{ - props.onScriptDataRemove(props.uploadedScripts[0]); + props.onScriptsChange( + { global: "windows", instanceId: null }, + [], + true, + ); }} />
- {props.removedScripts.map(s => s.scriptContent).join(", ")} + {props.removedScripts.map(s => s.global || s.instanceId).join(", ")}
{ - props.onCancelScript("windows", null); + props.onScriptsChange( + { global: "windows", instanceId: null }, + [], + false, + ); props.scrollableRef && props.scrollableRef(null as any as HTMLElement); }} @@ -51,11 +59,18 @@ jest.mock("@src/components/modules/WizardModule/WizardScripts", () => ({
{ - props.onScriptUpload({ - scriptContent: `script-content-${Math.random()}`, - fileName: `script-name.ps1`, - global: "windows", - }); + props.onScriptsChange( + { global: "windows", instanceId: null }, + [ + { + scriptContent: `script-content-${Math.random()}`, + fileName: `script-name.ps1`, + global: "windows", + phase: "osmorphing_post_os_mount", + }, + ], + false, + ); }} />
@@ -124,9 +139,8 @@ describe("ReplicaDeploymentOptions", () => { ); expect(getByTestId("ScriptsRemoved").textContent).toBe(""); fireEvent.click(getByTestId("ScriptsRemove")); - expect(getByTestId("ScriptsRemoved").textContent).toContain( - "script-content", - ); + expect(getByTestId("ScriptsUploaded").textContent).toBe(""); + expect(getByTestId("ScriptsRemoved").textContent).toContain("windows"); }); it("doesn't render minion pool mappings", () => { diff --git a/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx index 97bb9937..00671acb 100644 --- a/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx +++ b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx @@ -46,7 +46,12 @@ import { StorageMap, } from "@src/@types/Endpoint"; import type { Field } from "@src/@types/Field"; -import type { Instance, InstanceScript } from "@src/@types/Instance"; +import type { + Instance, + InstanceScript, + UserScriptTarget, +} from "@src/@types/Instance"; +import { applyUserScriptsChange } from "@src/utils/UserScriptUtils"; import { Network, NetworkMap, @@ -762,27 +767,14 @@ class TransferItemModal extends React.Component { }); } - handleCancelScript( - global: "windows" | "linux" | null, - instanceName: string | null, + handleScriptsChange( + target: UserScriptTarget, + scripts: InstanceScript[], + hadExisting: boolean, ) { - this.setState(prevState => ({ - uploadedScripts: prevState.uploadedScripts.filter(s => - global ? s.global !== global : s.instanceId !== instanceName, - ), - })); - } - - handleScriptUpload(script: InstanceScript) { - this.setState(prevState => ({ - uploadedScripts: [...prevState.uploadedScripts, script], - })); - } - - handleScriptDataRemove(script: InstanceScript) { - this.setState(prevState => ({ - removedScripts: [...prevState.removedScripts, script], - })); + this.setState(prevState => + applyUserScriptsChange(prevState, target, scripts, hadExisting), + ); } handleStorageChange(mapping: StorageMap) { @@ -963,14 +955,8 @@ class TransferItemModal extends React.Component { { - this.handleScriptUpload(s); - }} - onScriptDataRemove={s => { - this.handleScriptDataRemove(s); - }} - onCancelScript={(g, i) => { - this.handleCancelScript(g, i); + onScriptsChange={(target, scripts, hadExisting) => { + this.handleScriptsChange(target, scripts, hadExisting); }} uploadedScripts={this.state.uploadedScripts} removedScripts={this.state.removedScripts} diff --git a/src/components/modules/WizardModule/WizardPageContent/WizardPageContent.tsx b/src/components/modules/WizardModule/WizardPageContent/WizardPageContent.tsx index 3aa88970..2f3d15aa 100644 --- a/src/components/modules/WizardModule/WizardPageContent/WizardPageContent.tsx +++ b/src/components/modules/WizardModule/WizardPageContent/WizardPageContent.tsx @@ -53,7 +53,11 @@ import configLoader from "@src/utils/Config"; import transferItemIcon from "./images/transferItemIcon"; import type { WizardData, WizardPage } from "@src/@types/WizardData"; -import type { Instance, InstanceScript } from "@src/@types/Instance"; +import type { + Instance, + InstanceScript, + UserScriptTarget, +} from "@src/@types/Instance"; import type { Field } from "@src/@types/Field"; import type { Schedule as ScheduleType } from "@src/@types/Schedule"; @@ -174,10 +178,9 @@ type Props = { onContentRef: (ref: any) => void; onReloadOptionsClick: () => void; onReloadNetworksClick: () => void; - onUserScriptUpload: (instanceScript: InstanceScript) => void; - onCancelUploadedScript: ( - global: string | null, - instanceName: string | null, + onUserScriptsChange: ( + target: UserScriptTarget, + scripts: InstanceScript[], ) => void; onTransferExecuteOptionsChange: (field: Field, value: any) => void; }; @@ -604,12 +607,10 @@ class WizardPageContent extends React.Component { body = ( {}} /> ); break; diff --git a/src/components/modules/WizardModule/WizardScripts/UserScriptsModal.spec.tsx b/src/components/modules/WizardModule/WizardScripts/UserScriptsModal.spec.tsx new file mode 100644 index 00000000..9f87e4c9 --- /dev/null +++ b/src/components/modules/WizardModule/WizardScripts/UserScriptsModal.spec.tsx @@ -0,0 +1,109 @@ +/* +Copyright (C) 2026 Cloudbase Solutions SRL +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +import React from "react"; + +import { fireEvent, render } from "@testing-library/react"; + +import DomUtils from "@src/utils/DomUtils"; + +import UserScriptsModal from "./UserScriptsModal"; + +describe("UserScriptsModal", () => { + const baseProps = { + title: "Linux Script File", + global: "linux" as const, + instanceId: null, + onRequestClose: jest.fn(), + onSave: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders a file picker per phase with the three phase labels", () => { + const { getByText, getAllByText } = render( + , + ); + expect(getByText("OS morphing: before mount")).toBeTruthy(); + expect(getByText("OS morphing: after mount")).toBeTruthy(); + expect(getByText("VM first boot script")).toBeTruthy(); + expect(getAllByText("Choose File...").length).toBe(3); + }); + + it("saves one script per configured phase and skips empty ones", () => { + const onSave = jest.fn(); + const { getByText } = render( + , + ); + fireEvent.click(getByText("Save")); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith([ + { + global: "linux", + instanceId: null, + phase: "osmorphing_pre_os_mount", + scriptContent: "echo pre", + fileName: "pre.sh", + }, + { + global: "linux", + instanceId: null, + phase: "replica_first_boot", + scriptContent: "echo boot", + fileName: null, + }, + ]); + }); + + it("shows the file name + Remove for a configured phase, and Remove clears it", () => { + const onSave = jest.fn(); + const { getByText, getAllByText } = render( + , + ); + expect(getByText("saved.sh")).toBeTruthy(); + expect(getAllByText("Choose File...").length).toBe(2); + + const downloadSpy = jest + .spyOn(DomUtils, "download") + .mockImplementation(() => {}); + fireEvent.click(getByText("Download")); + expect(downloadSpy).toHaveBeenCalledWith("echo saved", "saved.sh"); + downloadSpy.mockRestore(); + + fireEvent.click(getByText("Remove")); + expect(getAllByText("Choose File...").length).toBe(3); + + fireEvent.click(getByText("Save")); + expect(onSave).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/components/modules/WizardModule/WizardScripts/UserScriptsModal.tsx b/src/components/modules/WizardModule/WizardScripts/UserScriptsModal.tsx new file mode 100644 index 00000000..d1d4dc10 --- /dev/null +++ b/src/components/modules/WizardModule/WizardScripts/UserScriptsModal.tsx @@ -0,0 +1,228 @@ +/* +Copyright (C) 2026 Cloudbase Solutions SRL +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +import React from "react"; +import styled from "styled-components"; + +import { ThemePalette, ThemeProps } from "@src/components/Theme"; +import Button from "@src/components/ui/Button"; +import InfoIcon from "@src/components/ui/InfoIcon"; +import DomUtils from "@src/utils/DomUtils"; +import FileUtils from "@src/utils/FileUtils"; + +import type { InstanceScript } from "@src/@types/Instance"; +import { + USER_SCRIPT_PHASE_DESCRIPTIONS, + USER_SCRIPT_PHASE_OPTIONS, + USER_SCRIPT_PHASES, + UserScriptPhase, +} from "@src/@types/Instance"; + +export type PhaseScript = { content: string | null; fileName: string | null }; +export type ScriptsByPhase = Partial>; + +const Wrapper = styled.div` + padding: 32px; + display: flex; + flex-direction: column; +`; +const PhaseRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid ${ThemePalette.grayscale[1]}; + padding: 16px 0; + &:last-of-type { + border-bottom: 1px solid ${ThemePalette.grayscale[1]}; + } +`; +const PhaseLabel = styled.div` + display: flex; + align-items: center; + font-weight: ${ThemeProps.fontWeights.medium}; +`; +const InfoIconStyled = styled(InfoIcon)` + margin-left: 8px; +`; +const PhaseControl = styled.div` + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: 16px; +`; +const FileName = styled.div` + max-width: 180px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + margin-right: 16px; +`; +const ActionLink = styled.div<{ red?: boolean }>` + color: ${props => (props.red ? ThemePalette.alert : ThemePalette.primary)}; + cursor: pointer; + :hover { + text-decoration: underline; + } +`; +const FakeFileInput = styled.input` + position: absolute; + opacity: 0; + top: -99999px; +`; +const Buttons = styled.div` + display: flex; + justify-content: space-between; + margin-top: 24px; +`; + +type Props = { + title: string; + global: "windows" | "linux" | null; + instanceId: string | null; + scriptsByPhase: ScriptsByPhase; + onRequestClose: () => void; + onSave: (scripts: InstanceScript[]) => void; +}; +type State = { + scriptsByPhase: ScriptsByPhase; +}; + +class UserScriptsModal extends React.Component { + fileInputRefs: { [phase: string]: HTMLInputElement | null } = {}; + + constructor(props: Props) { + super(props); + this.state = { scriptsByPhase: { ...props.scriptsByPhase } }; + } + + getPhaseLabel(phase: UserScriptPhase): string { + return ( + USER_SCRIPT_PHASE_OPTIONS.find(o => o.value === phase)?.label || phase + ); + } + + setPhaseScript(phase: UserScriptPhase, script: PhaseScript) { + this.setState(prevState => ({ + scriptsByPhase: { ...prevState.scriptsByPhase, [phase]: script }, + })); + } + + async handleFileUpload(phase: UserScriptPhase, files: FileList | null) { + if (!files || !files.length) { + return; + } + const fileName = files[0].name; + const content = await FileUtils.readTextFromFirstFile(files); + this.setPhaseScript(phase, { content: content ?? null, fileName }); + } + + handleRemove(phase: UserScriptPhase) { + this.setPhaseScript(phase, { content: null, fileName: null }); + const ref = this.fileInputRefs[phase]; + if (ref) { + ref.value = ""; + } + } + + handleDownload(phase: UserScriptPhase) { + const entry = this.state.scriptsByPhase[phase]; + if (!entry?.content) { + return; + } + const baseName = this.props.global || this.props.instanceId || "script"; + DomUtils.download(entry.content, entry.fileName || `${baseName}_${phase}`); + } + + handleSave() { + const scripts: InstanceScript[] = USER_SCRIPT_PHASES.reduce< + InstanceScript[] + >((acc, phase) => { + const entry = this.state.scriptsByPhase[phase]; + if (entry && entry.content && entry.content.trim() !== "") { + acc.push({ + global: this.props.global, + instanceId: this.props.instanceId, + phase, + scriptContent: entry.content, + fileName: entry.fileName ?? null, + }); + } + return acc; + }, []); + this.props.onSave(scripts); + this.props.onRequestClose(); + } + + render() { + return ( + + {USER_SCRIPT_PHASES.map(phase => { + const entry = this.state.scriptsByPhase[phase]; + const hasContent = Boolean(entry?.content); + return ( + + + {this.getPhaseLabel(phase)} + + + + {hasContent ? ( + <> + + {entry?.fileName || "Script selected"} + + this.handleDownload(phase)} + > + Download + + this.handleRemove(phase)}> + Remove + + + ) : ( + { + this.fileInputRefs[phase]?.click(); + }} + > + Choose File... + + )} + { + this.fileInputRefs[phase] = r; + }} + onChange={e => { + this.handleFileUpload(phase, e.target.files); + }} + /> + + + ); + })} + + + + + + ); + } +} + +export default UserScriptsModal; diff --git a/src/components/modules/WizardModule/WizardScripts/WizardScripts.spec.tsx b/src/components/modules/WizardModule/WizardScripts/WizardScripts.spec.tsx index 6f5cc0df..3104d4e2 100644 --- a/src/components/modules/WizardModule/WizardScripts/WizardScripts.spec.tsx +++ b/src/components/modules/WizardModule/WizardScripts/WizardScripts.spec.tsx @@ -14,7 +14,7 @@ along with this program. If not, see . import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { INSTANCE_MOCK } from "@tests/mocks/InstancesMock"; import WizardScripts from "./"; @@ -28,14 +28,43 @@ describe("WizardScripts", () => { uploadedScripts: [], removedScripts: [], userScriptData: null, - onScriptUpload: jest.fn(), - onCancelScript: jest.fn(), - onScriptDataRemove: jest.fn(), + onScriptsChange: jest.fn(), }; }); it("renders without crashing", () => { - const { getByText } = render(); + const { getByText, getAllByText } = render( + , + ); expect(getByText(INSTANCE_MOCK.name)).toBeTruthy(); + expect(getAllByText("Choose Scripts").length).toBeGreaterThan(0); + }); + + it("offers 'Edit Scripts' for a configured target and 'Choose Scripts' otherwise", () => { + const { getByText, getAllByText } = render( + , + ); + expect(getByText("Edit Scripts")).toBeTruthy(); + expect(getAllByText("Choose Scripts").length).toBe(2); + }); + + it("opens the per-phase modal showing all three phases", () => { + const { getAllByText } = render(); + fireEvent.click(getAllByText("Choose Scripts")[0]); + expect(screen.getByText("Windows Script File - User Scripts")).toBeTruthy(); + expect(screen.getByText("OS morphing: before mount")).toBeTruthy(); + expect(screen.getByText("OS morphing: after mount")).toBeTruthy(); + expect(screen.getByText("VM first boot script")).toBeTruthy(); + expect(screen.getAllByText("Choose File...").length).toBe(3); }); }); diff --git a/src/components/modules/WizardModule/WizardScripts/WizardScripts.tsx b/src/components/modules/WizardModule/WizardScripts/WizardScripts.tsx index 9e477ae2..a02bf34f 100644 --- a/src/components/modules/WizardModule/WizardScripts/WizardScripts.tsx +++ b/src/components/modules/WizardModule/WizardScripts/WizardScripts.tsx @@ -14,20 +14,47 @@ along with this program. If not, see . import { observer } from "mobx-react"; import React from "react"; -import styled, { css } from "styled-components"; +import styled from "styled-components"; -import { UserScriptData } from "@src/@types/MainItem"; +import { UserScriptData, UserScriptValue } from "@src/@types/MainItem"; import { InstanceImage } from "@src/components/modules/WizardModule/WizardInstances"; import { ThemePalette, ThemeProps } from "@src/components/Theme"; import InfoIcon from "@src/components/ui/InfoIcon"; +import Modal from "@src/components/ui/Modal"; import StatusIcon from "@src/components/ui/StatusComponents/StatusIcon"; -import { Close as InputClose } from "@src/components/ui/TextInput"; -import DomUtils from "@src/utils/DomUtils"; -import FileUtils from "@src/utils/FileUtils"; import scriptItemImage from "./images/script-item.svg"; +import UserScriptsModal, { ScriptsByPhase } from "./UserScriptsModal"; + +import type { + Instance, + InstanceScript, + UserScriptTarget, +} from "@src/@types/Instance"; +import { + DEFAULT_USER_SCRIPT_PHASE, + USER_SCRIPT_PHASES, + UserScriptPhase, +} from "@src/@types/Instance"; + +const parseScriptValueByPhase = (value: UserScriptValue): ScriptsByPhase => { + const map: ScriptsByPhase = {}; + if (!value) { + return map; + } + if (typeof value === "string") { + map[DEFAULT_USER_SCRIPT_PHASE] = { content: value, fileName: null }; + return map; + } + value.forEach(item => { + map[item.phase || DEFAULT_USER_SCRIPT_PHASE] = { + content: item.payload, + fileName: null, + }; + }); + return map; +}; -import type { Instance, InstanceScript } from "@src/@types/Instance"; const Wrapper = styled.div` width: 100%; display: flex; @@ -108,45 +135,7 @@ const LinkButton = styled.div` text-decoration: underline; } `; -const UploadedScript = styled.div` - display: flex; - position: relative; -`; -const UploadedScriptFileName = styled.div` - max-width: 124px; - text-overflow: ellipsis; - overflow: hidden; - margin-right: 32px; - white-space: nowrap; -`; -const InputCloseStyled = styled(InputClose)` - top: 0px; -`; -const FakeFileInput = styled.input` - position: absolute; - opacity: 0; - top: -99999px; -`; -const ScriptDataActions = styled.div` - display: flex; - margin-left: -8px; - margin-top: 8px; - > div { - margin-left: 8px; - } -`; -const ScriptDataAction = styled.div<{ red?: boolean; disabled?: boolean }>` - color: ${props => (props.red ? ThemePalette.alert : ThemePalette.primary)}; - cursor: pointer; - ${props => - props.disabled - ? css` - opacity: 0.6; - cursor: default; - ` - : ""} - font-size: 12px; -`; +type ModalTarget = UserScriptTarget & { title: string }; type Props = { instances: Instance[]; @@ -156,44 +145,76 @@ type Props = { loadingInstances?: boolean; userScriptData: UserScriptData | null | undefined; style?: React.CSSProperties; - onScriptUpload: (instanceScript: InstanceScript) => void; - onCancelScript: ( - global: "windows" | "linux" | null, - instanceName: string | null, - ) => void; onScrollableRef?: (ref: HTMLElement) => void; scrollableRef?: (r: HTMLElement) => void; - onScriptDataRemove: (script: InstanceScript) => void; + onScriptsChange: ( + target: UserScriptTarget, + scripts: InstanceScript[], + hadExisting: boolean, + ) => void; }; -type FileInputRefs = { - [prop: string]: { - inputRef: HTMLInputElement; - }; +type State = { + modalTarget: ModalTarget | null; }; @observer -class WizardScripts extends React.Component { - fileInputRefs: FileInputRefs = {}; +class WizardScripts extends React.Component { + state: State = { + modalTarget: null, + }; - async handleFileUpload( - files: FileList | null, - global: "windows" | "linux" | null, - instanceId: string | null, - ) { - if (!files || !files.length) { - return; + matchesTarget(script: InstanceScript, target: UserScriptTarget): boolean { + return target.global + ? script.global === target.global + : script.instanceId === target.instanceId; + } + + getBaseValue(target: UserScriptTarget): UserScriptValue { + if (target.global) { + return this.props.userScriptData?.global?.[target.global] ?? null; + } + if (target.instanceId) { + return this.props.userScriptData?.instances?.[target.instanceId] ?? null; + } + return null; + } + + getScriptsByPhase(target: UserScriptTarget): ScriptsByPhase { + const uploaded = this.props.uploadedScripts.filter(s => + this.matchesTarget(s, target), + ); + const isRemoved = this.props.removedScripts.some(s => + this.matchesTarget(s, target), + ); + if (uploaded.length || isRemoved) { + const map: ScriptsByPhase = {}; + uploaded.forEach(s => { + map[s.phase || DEFAULT_USER_SCRIPT_PHASE] = { + content: s.scriptContent, + fileName: s.fileName, + }; + }); + return map; } - const fileName = files[0].name; - const scriptContent = await FileUtils.readTextFromFirstFile(files); - this.props.onScriptUpload({ - instanceId, - global, - fileName, - scriptContent: scriptContent || "", - }); + return parseScriptValueByPhase(this.getBaseValue(target)); + } + + getConfiguredPhases(target: UserScriptTarget): UserScriptPhase[] { + const map = this.getScriptsByPhase(target); + return USER_SCRIPT_PHASES.filter(phase => map[phase]?.content); + } + + computeHadExisting(target: UserScriptTarget): boolean { + const baseMap = parseScriptValueByPhase(this.getBaseValue(target)); + return USER_SCRIPT_PHASES.some(phase => baseMap[phase]?.content); } - handleScriptDataDownload(scriptData: string, fileName: string) { - DomUtils.download(scriptData, fileName); + handleModalSave(target: ModalTarget, scripts: InstanceScript[]) { + this.props.onScriptsChange( + { global: target.global, instanceId: target.instanceId }, + scripts, + this.computeHadExisting(target), + ); + this.setState({ modalTarget: null }); } renderScriptItem(opts: { @@ -203,24 +224,11 @@ class WizardScripts extends React.Component { subtitle?: string; }) { const { global, instanceId, title, subtitle } = opts; - const uploadedScript = this.props.uploadedScripts.find(s => - s.instanceId - ? s.instanceId === instanceId - : s.global - ? s.global === global - : false, - ); - let scriptData: string | null | undefined = null; - if (global) { - scriptData = this.props.userScriptData?.global?.[global]; - } else if (instanceId) { - scriptData = this.props.userScriptData?.instances?.[instanceId]; - } - const isRemoved = Boolean( - this.props.removedScripts.find(s => - global ? s.global === global : s.instanceId === instanceId, - ), - ); + const target: UserScriptTarget = { + global: global ?? null, + instanceId: instanceId ?? null, + }; + const isConfigured = this.getConfiguredPhases(target).length > 0; return ( ); } @@ -323,7 +260,7 @@ class WizardScripts extends React.Component { Global Scripts @@ -351,7 +288,7 @@ class WizardScripts extends React.Component { {!this.props.loadingInstances ? ( ) : null} {this.props.loadingInstances ? ( @@ -383,6 +320,7 @@ class WizardScripts extends React.Component { } render() { + const { modalTarget } = this.state; return ( { > {this.renderScriptGroup("global")} {this.renderScriptGroup("instance")} + this.setState({ modalTarget: null })} + > + {modalTarget ? ( + this.setState({ modalTarget: null })} + onSave={scripts => this.handleModalSave(modalTarget, scripts)} + /> + ) : null} + ); } diff --git a/src/components/modules/WizardModule/WizardSummary/WizardSummary.tsx b/src/components/modules/WizardModule/WizardSummary/WizardSummary.tsx index 35578570..dcb12b0d 100644 --- a/src/components/modules/WizardModule/WizardSummary/WizardSummary.tsx +++ b/src/components/modules/WizardModule/WizardSummary/WizardSummary.tsx @@ -35,6 +35,10 @@ import type { Schedule } from "@src/@types/Schedule"; import type { WizardData } from "@src/@types/WizardData"; import type { StorageMap, StorageBackend } from "@src/@types/Endpoint"; import type { Instance, Disk, InstanceScript } from "@src/@types/Instance"; +import { + DEFAULT_USER_SCRIPT_PHASE, + USER_SCRIPT_PHASE_OPTIONS, +} from "@src/@types/Instance"; import type { Field } from "@src/@types/Field"; const Wrapper = styled.div` @@ -722,26 +726,35 @@ class WizardSummary extends React.Component {
Uploaded User Scripts - {this.props.uploadedUserScripts.map(s => ( - - - {s.global - ? s.global === "windows" - ? "Global Windows Script" - : "Global Linux Script" - : s.instanceId} - - {s.fileName} - - ))} + {this.props.uploadedUserScripts.map(s => { + const phase = s.phase || DEFAULT_USER_SCRIPT_PHASE; + const phaseLabel = + USER_SCRIPT_PHASE_OPTIONS.find(o => o.value === phase)?.label || + phase; + return ( + +
+ + {s.global + ? s.global === "windows" + ? "Global Windows Script" + : "Global Linux Script" + : s.instanceId} + + {phaseLabel} +
+ {s.fileName} +
+ ); + })}
); diff --git a/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx b/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx index c7398a05..ed173839 100644 --- a/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx +++ b/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx @@ -365,6 +365,17 @@ class TransferDetailsPage extends React.Component { this.setState({ dbInstancesDetails: instancesDetails }); } + populateInstanceDetails() { + const transfer = this.transfer; + if ( + transfer && + !this.state.dbInstancesDetails.length && + this.hasStoredVmInfo(transfer.info) + ) { + this.populateInstanceStoreFromTransferInfo(transfer.info); + } + } + isExecuteDisabled() { const transfer = this.transfer; if (!transfer) { @@ -471,10 +482,12 @@ class TransferDetailsPage extends React.Component { } handleCreateDeploymentClick() { + this.populateInstanceDetails(); this.setState({ showDeploymentModal: true, pausePolling: true }); } handleTransferEditClick() { + this.populateInstanceDetails(); this.setState({ showEditModal: true, pausePolling: true }); } @@ -633,6 +646,7 @@ class TransferDetailsPage extends React.Component { } })(), ]); + this.populateInstanceDetails(); setTimeout(() => { this.pollData(); diff --git a/src/components/smart/WizardPage/WizardPage.tsx b/src/components/smart/WizardPage/WizardPage.tsx index f9fa5131..f07c2f3b 100644 --- a/src/components/smart/WizardPage/WizardPage.tsx +++ b/src/components/smart/WizardPage/WizardPage.tsx @@ -52,7 +52,11 @@ import type { Endpoint as EndpointType, StorageMap, } from "@src/@types/Endpoint"; -import type { Instance, InstanceScript } from "@src/@types/Instance"; +import type { + Instance, + InstanceScript, + UserScriptTarget, +} from "@src/@types/Instance"; import type { Field } from "@src/@types/Field"; import type { Schedule } from "@src/@types/Schedule"; import type { WizardPage as WizardPageType } from "@src/@types/WizardData"; @@ -834,15 +838,8 @@ class WizardPage extends React.Component { transferStore.execute(transfer.id, executeNowOptions); } - handleCancelUploadedScript( - global: string | null, - instanceName: string | null, - ) { - wizardStore.cancelUploadedScript(global, instanceName); - } - - handleUserScriptUpload(instanceScript: InstanceScript) { - wizardStore.uploadUserScript(instanceScript); + handleUserScriptsChange(target: UserScriptTarget, scripts: InstanceScript[]) { + wizardStore.setUserScripts(target, scripts); } render() { @@ -944,11 +941,8 @@ class WizardPage extends React.Component { this.loadNetworks(false); }} uploadedUserScripts={wizardStore.uploadedUserScripts} - onCancelUploadedScript={(g, i) => { - this.handleCancelUploadedScript(g, i); - }} - onUserScriptUpload={s => { - this.handleUserScriptUpload(s); + onUserScriptsChange={(target, scripts) => { + this.handleUserScriptsChange(target, scripts); }} onTransferExecuteOptionsChange={(field, value) => { this.handleTransferExecuteOptionsChange(field, value); diff --git a/src/plugins/default/OptionsSchemaPlugin.spec.tsx b/src/plugins/default/OptionsSchemaPlugin.spec.tsx new file mode 100644 index 00000000..1b49c17a --- /dev/null +++ b/src/plugins/default/OptionsSchemaPlugin.spec.tsx @@ -0,0 +1,196 @@ +/* +Copyright (C) 2026 Cloudbase Solutions SRL +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +import type { InstanceScript } from "@src/@types/Instance"; +import { OptionsSchemaPlugin } from "@src/plugins"; + +describe("OptionsSchemaPlugin.getUserScripts", () => { + let parser: ReturnType; + + beforeEach(() => { + parser = OptionsSchemaPlugin.for("default" as any); + }); + + it("defaults to osmorphing_post_os_mount when no phase is provided", () => { + const uploaded: InstanceScript[] = [ + { global: "linux", scriptContent: "echo hi", fileName: "s.sh" }, + ]; + const payload = parser.getUserScripts(uploaded, [], null); + expect(payload.global.linux).toEqual([ + { phase: "osmorphing_post_os_mount", payload: "echo hi" }, + ]); + }); + + it.each([ + "osmorphing_pre_os_mount", + "osmorphing_post_os_mount", + "replica_first_boot", + ] as const)("serializes the explicit %s phase", phase => { + const uploaded: InstanceScript[] = [ + { + global: "windows", + scriptContent: "Write-Host hi", + fileName: "s.ps1", + phase, + }, + ]; + const payload = parser.getUserScripts(uploaded, [], null); + expect(payload.global.windows).toEqual([ + { phase, payload: "Write-Host hi" }, + ]); + }); + + it("serializes global linux and windows scripts with their phases", () => { + const uploaded: InstanceScript[] = [ + { + global: "linux", + scriptContent: "echo linux", + fileName: "l.sh", + phase: "osmorphing_pre_os_mount", + }, + { + global: "windows", + scriptContent: "Write-Host win", + fileName: "w.ps1", + phase: "replica_first_boot", + }, + ]; + const payload = parser.getUserScripts(uploaded, [], null); + expect(payload.global).toEqual({ + linux: [{ phase: "osmorphing_pre_os_mount", payload: "echo linux" }], + windows: [{ phase: "replica_first_boot", payload: "Write-Host win" }], + }); + }); + + it("serializes an instance script with its phase", () => { + const uploaded: InstanceScript[] = [ + { + instanceId: "instance-1", + scriptContent: "echo instance", + fileName: "i.sh", + phase: "replica_first_boot", + }, + ]; + const payload = parser.getUserScripts(uploaded, [], null); + expect(payload.instances["instance-1"]).toEqual([ + { phase: "replica_first_boot", payload: "echo instance" }, + ]); + }); + + it("emits null for removed scripts to unregister them", () => { + const removed: InstanceScript[] = [ + { global: "linux", scriptContent: null, fileName: null }, + ]; + const payload = parser.getUserScripts([], removed, null); + expect(payload.global.linux).toBeNull(); + }); + + it("preserves pre-existing (e.g. legacy string) scripts in userScriptData", () => { + const existing = { + global: { windows: "legacy string script" }, + }; + const uploaded: InstanceScript[] = [ + { + global: "linux", + scriptContent: "echo new", + fileName: "n.sh", + phase: "osmorphing_post_os_mount", + }, + ]; + const payload = parser.getUserScripts(uploaded, [], existing as any); + expect(payload.global.windows).toBe("legacy string script"); + expect(payload.global.linux).toEqual([ + { phase: "osmorphing_post_os_mount", payload: "echo new" }, + ]); + }); + + it("groups multiple scripts for one target into a single phase list", () => { + const uploaded: InstanceScript[] = [ + { + global: "linux", + scriptContent: "echo pre", + fileName: null, + phase: "osmorphing_pre_os_mount", + }, + { + global: "linux", + scriptContent: "echo post", + fileName: null, + phase: "osmorphing_post_os_mount", + }, + { + global: "linux", + scriptContent: "echo boot", + fileName: null, + phase: "replica_first_boot", + }, + ]; + const payload = parser.getUserScripts(uploaded, [], null); + expect(payload.global.linux).toEqual([ + { phase: "osmorphing_pre_os_mount", payload: "echo pre" }, + { phase: "osmorphing_post_os_mount", payload: "echo post" }, + { phase: "replica_first_boot", payload: "echo boot" }, + ]); + }); + + it("groups multiple scripts for one instance with different phases", () => { + const uploaded: InstanceScript[] = [ + { + instanceId: "instance-1", + scriptContent: "echo pre", + fileName: null, + phase: "osmorphing_pre_os_mount", + }, + { + instanceId: "instance-1", + scriptContent: "echo boot", + fileName: null, + phase: "replica_first_boot", + }, + ]; + const payload = parser.getUserScripts(uploaded, [], null); + expect(payload.instances["instance-1"]).toEqual([ + { phase: "osmorphing_pre_os_mount", payload: "echo pre" }, + { phase: "replica_first_boot", payload: "echo boot" }, + ]); + }); + + it("never emits empty-string payloads and nulls an empty target", () => { + const uploaded: InstanceScript[] = [ + { + global: "linux", + scriptContent: "", + fileName: null, + phase: "osmorphing_pre_os_mount", + }, + { + global: "linux", + scriptContent: " ", + fileName: null, + phase: "osmorphing_post_os_mount", + }, + { + global: "windows", + scriptContent: "Write-Host hi", + fileName: null, + phase: "osmorphing_post_os_mount", + }, + ]; + const payload = parser.getUserScripts(uploaded, [], null); + expect(payload.global.linux).toBeNull(); + expect(payload.global.windows).toEqual([ + { phase: "osmorphing_post_os_mount", payload: "Write-Host hi" }, + ]); + }); +}); diff --git a/src/plugins/default/OptionsSchemaPlugin.ts b/src/plugins/default/OptionsSchemaPlugin.ts index 0c8a85e8..3ebe7653 100644 --- a/src/plugins/default/OptionsSchemaPlugin.ts +++ b/src/plugins/default/OptionsSchemaPlugin.ts @@ -19,6 +19,7 @@ import type { OptionValues, StorageMap } from "@src/@types/Endpoint"; import type { SchemaProperties, SchemaDefinitions } from "@src/@types/Schema"; import type { NetworkMap } from "@src/@types/Network"; import type { InstanceScript } from "@src/@types/Instance"; +import { DEFAULT_USER_SCRIPT_PHASE } from "@src/@types/Instance"; import { executionOptions } from "@src/constants"; import { UserScriptData } from "@src/@types/MainItem"; import { defaultSchemaToFields } from "./ConnectionSchemaPlugin"; @@ -383,10 +384,22 @@ export default class OptionsSchemaParserBase { scriptProp: "global" | "instanceId", payloadProp: "global" | "instances", ) => { - if (!scripts.length) { - return; - } - payload[payloadProp] = payload[payloadProp] || {}; + scripts.forEach(script => { + const scriptValue = script[scriptProp]; + if (!scriptValue) { + return; + } + payload[payloadProp] = payload[payloadProp] || {}; + payload[payloadProp][scriptValue] = null; + }); + }; + + const setPayloadUploaded = ( + scripts: InstanceScript[], + scriptProp: "global" | "instanceId", + payloadProp: "global" | "instances", + ) => { + const byTarget: { [target: string]: InstanceScript[] } = {}; scripts.forEach(script => { const scriptValue = script[scriptProp]; if (!scriptValue) { @@ -394,7 +407,18 @@ export default class OptionsSchemaParserBase { `The uploaded script structure is missing the '${scriptProp}' property`, ); } - payload[payloadProp][scriptValue] = script.scriptContent; + byTarget[scriptValue] = byTarget[scriptValue] || []; + byTarget[scriptValue].push(script); + }); + Object.keys(byTarget).forEach(scriptValue => { + payload[payloadProp] = payload[payloadProp] || {}; + const entries = byTarget[scriptValue] + .filter(s => s.scriptContent != null && s.scriptContent.trim() !== "") + .map(s => ({ + phase: s.phase || DEFAULT_USER_SCRIPT_PHASE, + payload: s.scriptContent, + })); + payload[payloadProp][scriptValue] = entries.length ? entries : null; }); }; @@ -408,12 +432,12 @@ export default class OptionsSchemaParserBase { "instanceId", "instances", ); - setPayload( + setPayloadUploaded( uploadedUserScripts.filter(s => s.global), "global", "global", ); - setPayload( + setPayloadUploaded( uploadedUserScripts.filter(s => s.instanceId), "instanceId", "instances", diff --git a/src/stores/WizardStore.ts b/src/stores/WizardStore.ts index 0f82bae5..474b541e 100644 --- a/src/stores/WizardStore.ts +++ b/src/stores/WizardStore.ts @@ -15,7 +15,11 @@ along with this program. If not, see . import { observable, action, runInAction } from "mobx"; import type { WizardData, WizardPage } from "@src/@types/WizardData"; -import type { Instance, InstanceScript } from "@src/@types/Instance"; +import type { + Instance, + InstanceScript, + UserScriptTarget, +} from "@src/@types/Instance"; import type { Field } from "@src/@types/Field"; import type { NetworkMap } from "@src/@types/Network"; import type { StorageMap } from "@src/@types/Endpoint"; @@ -421,17 +425,15 @@ class WizardStore { } } - @action cancelUploadedScript( - global: string | null, - instanceName: string | null, - ) { - this.uploadedUserScripts = this.uploadedUserScripts.filter(s => - global ? s.global !== global : s.instanceId !== instanceName, - ); - } - - @action uploadUserScript(instanceScript: InstanceScript) { - this.uploadedUserScripts = [...this.uploadedUserScripts, instanceScript]; + @action setUserScripts(target: UserScriptTarget, scripts: InstanceScript[]) { + const matches = (s: InstanceScript) => + target.global + ? s.global === target.global + : s.instanceId === target.instanceId; + this.uploadedUserScripts = [ + ...this.uploadedUserScripts.filter(s => !matches(s)), + ...scripts, + ]; } @action clearUploadedUserScripts() { diff --git a/src/utils/UserScriptUtils.spec.tsx b/src/utils/UserScriptUtils.spec.tsx new file mode 100644 index 00000000..dc8612fe --- /dev/null +++ b/src/utils/UserScriptUtils.spec.tsx @@ -0,0 +1,128 @@ +/* +Copyright (C) 2026 Cloudbase Solutions SRL +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +import { applyUserScriptsChange } from "./UserScriptUtils"; + +import type { InstanceScript } from "@src/@types/Instance"; + +const script = (over: Partial): InstanceScript => ({ + global: null, + instanceId: null, + scriptContent: "echo hi", + fileName: null, + ...over, +}); + +const empty = { uploadedScripts: [], removedScripts: [] }; + +describe("applyUserScriptsChange", () => { + it("adds new scripts for a target to uploadedScripts", () => { + const win = script({ global: "windows", phase: "replica_first_boot" }); + + const next = applyUserScriptsChange( + empty, + { global: "windows", instanceId: null }, + [win], + false, + ); + + expect(next.uploadedScripts).toEqual([win]); + expect(next.removedScripts).toEqual([]); + }); + + it("replaces a target's scripts without touching other targets", () => { + const linux = script({ global: "linux" }); + const oldWin = script({ global: "windows", scriptContent: "old" }); + const newWin = script({ global: "windows", scriptContent: "new" }); + + const next = applyUserScriptsChange( + { uploadedScripts: [linux, oldWin], removedScripts: [] }, + { global: "windows", instanceId: null }, + [newWin], + false, + ); + + expect(next.uploadedScripts).toEqual([linux, newWin]); + expect(next.removedScripts).toEqual([]); + }); + + it("records a null removed entry when clearing a previously-saved target", () => { + const win = script({ global: "windows" }); + + const next = applyUserScriptsChange( + { uploadedScripts: [win], removedScripts: [] }, + { global: "windows", instanceId: null }, + [], + true, + ); + + expect(next.uploadedScripts).toEqual([]); + expect(next.removedScripts).toEqual([ + { + global: "windows", + instanceId: null, + scriptContent: null, + fileName: null, + }, + ]); + }); + + it("does not record a removed entry when clearing a never-saved target", () => { + const win = script({ global: "windows" }); + + const next = applyUserScriptsChange( + { uploadedScripts: [win], removedScripts: [] }, + { global: "windows", instanceId: null }, + [], + false, + ); + + expect(next.uploadedScripts).toEqual([]); + expect(next.removedScripts).toEqual([]); + }); + + it("does not duplicate the removed entry when clearing twice", () => { + const removedWin = script({ + global: "windows", + scriptContent: null, + }); + + const next = applyUserScriptsChange( + { uploadedScripts: [], removedScripts: [removedWin] }, + { global: "windows", instanceId: null }, + [], + true, + ); + + expect(next.removedScripts).toHaveLength(1); + expect(next.removedScripts[0].global).toBe("windows"); + expect(next.removedScripts[0].scriptContent).toBeNull(); + }); + + it("matches instance targets by instanceId, independently of globals", () => { + const win = script({ global: "windows" }); + const inst = script({ instanceId: "i-1", scriptContent: "old" }); + const newInst = script({ instanceId: "i-1", scriptContent: "new" }); + + const next = applyUserScriptsChange( + { uploadedScripts: [win, inst], removedScripts: [] }, + { global: null, instanceId: "i-1" }, + [newInst], + false, + ); + + expect(next.uploadedScripts).toEqual([win, newInst]); + expect(next.removedScripts).toEqual([]); + }); +}); diff --git a/src/utils/UserScriptUtils.ts b/src/utils/UserScriptUtils.ts new file mode 100644 index 00000000..3607d9d3 --- /dev/null +++ b/src/utils/UserScriptUtils.ts @@ -0,0 +1,52 @@ +/* +Copyright (C) 2026 Cloudbase Solutions SRL +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +import type { InstanceScript, UserScriptTarget } from "@src/@types/Instance"; + +export type UserScriptsState = { + uploadedScripts: InstanceScript[]; + removedScripts: InstanceScript[]; +}; + +const matchesTarget = (script: InstanceScript, target: UserScriptTarget) => + target.global + ? script.global === target.global + : script.instanceId === target.instanceId; + +export const applyUserScriptsChange = ( + prev: UserScriptsState, + target: UserScriptTarget, + scripts: InstanceScript[], + hadExisting: boolean, +): UserScriptsState => { + const uploadedScripts = [ + ...prev.uploadedScripts.filter(s => !matchesTarget(s, target)), + ...scripts, + ]; + let removedScripts = prev.removedScripts.filter( + s => !matchesTarget(s, target), + ); + if (scripts.length === 0 && hadExisting) { + removedScripts = [ + ...removedScripts, + { + global: target.global, + instanceId: target.instanceId, + scriptContent: null, + fileName: null, + }, + ]; + } + return { uploadedScripts, removedScripts }; +};