Просмотр исходного кода

Add the per-phase `Choose Scripts` modal

Signed-off-by: Mihaela Balutoiu <mbalutoiu@cloudbasesolutions.com>
Mihaela Balutoiu 1 неделя назад
Родитель
Сommit
99be0b0586

+ 109 - 0
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 <http://www.gnu.org/licenses/>.
+*/
+
+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(
+      <UserScriptsModal {...baseProps} scriptsByPhase={{}} />,
+    );
+    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(
+      <UserScriptsModal
+        {...baseProps}
+        onSave={onSave}
+        scriptsByPhase={{
+          osmorphing_pre_os_mount: { content: "echo pre", fileName: "pre.sh" },
+          replica_first_boot: { content: "echo boot", fileName: null },
+        }}
+      />,
+    );
+    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(
+      <UserScriptsModal
+        {...baseProps}
+        onSave={onSave}
+        scriptsByPhase={{
+          osmorphing_post_os_mount: {
+            content: "echo saved",
+            fileName: "saved.sh",
+          },
+        }}
+      />,
+    );
+    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([]);
+  });
+});

+ 228 - 0
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 <http://www.gnu.org/licenses/>.
+*/
+
+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<Record<UserScriptPhase, PhaseScript>>;
+
+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<Props, State> {
+  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 (
+      <Wrapper>
+        {USER_SCRIPT_PHASES.map(phase => {
+          const entry = this.state.scriptsByPhase[phase];
+          const hasContent = Boolean(entry?.content);
+          return (
+            <PhaseRow key={phase}>
+              <PhaseLabel>
+                {this.getPhaseLabel(phase)}
+                <InfoIconStyled text={USER_SCRIPT_PHASE_DESCRIPTIONS[phase]} />
+              </PhaseLabel>
+              <PhaseControl>
+                {hasContent ? (
+                  <>
+                    <FileName title={entry?.fileName || "Script selected"}>
+                      {entry?.fileName || "Script selected"}
+                    </FileName>
+                    <ActionLink
+                      style={{ marginRight: "16px" }}
+                      onClick={() => this.handleDownload(phase)}
+                    >
+                      Download
+                    </ActionLink>
+                    <ActionLink red onClick={() => this.handleRemove(phase)}>
+                      Remove
+                    </ActionLink>
+                  </>
+                ) : (
+                  <ActionLink
+                    onClick={() => {
+                      this.fileInputRefs[phase]?.click();
+                    }}
+                  >
+                    Choose File...
+                  </ActionLink>
+                )}
+                <FakeFileInput
+                  type="file"
+                  ref={(r: HTMLInputElement) => {
+                    this.fileInputRefs[phase] = r;
+                  }}
+                  onChange={e => {
+                    this.handleFileUpload(phase, e.target.files);
+                  }}
+                />
+              </PhaseControl>
+            </PhaseRow>
+          );
+        })}
+        <Buttons>
+          <Button secondary hollow onClick={() => this.props.onRequestClose()}>
+            Cancel
+          </Button>
+          <Button onClick={() => this.handleSave()}>Save</Button>
+        </Buttons>
+      </Wrapper>
+    );
+  }
+}
+
+export default UserScriptsModal;