From de81a5d810f80303001f366844bc12c856121e23 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Fri, 1 Aug 2025 16:52:24 +0200 Subject: [PATCH] Modal: prepare for install flow --- .../ui/src/components/Modal/Modal.tsx | 2 + .../InstallModal/InstallModal.module.css | 0 .../InstallModal/InstallModal.stories.tsx | 18 ++ .../workflows/InstallModal/InstallModal.tsx | 302 ++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.module.css create mode 100644 pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.stories.tsx create mode 100644 pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.tsx diff --git a/pkgs/clan-app/ui/src/components/Modal/Modal.tsx b/pkgs/clan-app/ui/src/components/Modal/Modal.tsx index 28bb6dd75..28cac61a8 100644 --- a/pkgs/clan-app/ui/src/components/Modal/Modal.tsx +++ b/pkgs/clan-app/ui/src/components/Modal/Modal.tsx @@ -16,6 +16,7 @@ export interface ModalProps { children: (ctx: ModalContext) => JSX.Element; mount?: Node; class?: string; + header?: () => JSX.Element; } export const Modal = (props: ModalProps) => { @@ -43,6 +44,7 @@ export const Modal = (props: ModalProps) => { + {props.header?.()}
{props.children({ close: () => { diff --git a/pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.module.css b/pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.module.css new file mode 100644 index 000000000..e69de29bb diff --git a/pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.stories.tsx b/pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.stories.tsx new file mode 100644 index 000000000..57b8937f0 --- /dev/null +++ b/pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@kachurun/storybook-solid"; +import { InstallModal } from "./InstallModal"; +import { machine } from "os"; + +const meta: Meta = { + title: "workflows/InstallModal", + component: InstallModal, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + machineName: "Test Machine", + }, +}; diff --git a/pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.tsx b/pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.tsx new file mode 100644 index 000000000..43d138cca --- /dev/null +++ b/pkgs/clan-app/ui/src/workflows/InstallModal/InstallModal.tsx @@ -0,0 +1,302 @@ +import { Button } from "@/src/components/Button/Button"; +import { Modal } from "@/src/components/Modal/Modal"; +import { createForm, SubmitHandler } from "@modular-forms/solid"; +import { + Accessor, + Component, + createContext, + createSignal, + JSX, + Setter, + Show, + useContext, +} from "solid-js"; +import { Dynamic } from "solid-js/web"; + +export const InstallHeader = (props: { + machineName: string; + stepid: string; +}) => { + return ( +

+ Installing: {props.machineName} {props.stepid} +

+ ); +}; + +type Step = { + id: string; + title: Component<{ machineName: string; stepid: string }>; + content: Component; +}; + +type StepOptions = { + initialStep: Id; +}; + +function createStepper< + T extends readonly Step[], + StepId extends T[number]["id"], +>(s: { steps: T }, stepOpts: StepOptions): StepperReturn { + const [activeStep, setActiveStep] = createSignal( + stepOpts.initialStep, + ); + + /** + * Hooks to manage the current step in the workflow. + * It provides the active step and a function to set the active step. + */ + return { + activeStep, + setActiveStep, + currentStep: () => { + const curr = s.steps.find((step) => step.id === activeStep()); + if (!curr) { + throw new Error(`Step with id ${activeStep()} not found`); + } + return curr; + }, + next: () => { + const currentIndex = s.steps.findIndex( + (step) => step.id === activeStep(), + ); + if (currentIndex === -1 || currentIndex === s.steps.length - 1) { + throw new Error("No next step available"); + } + setActiveStep(s.steps[currentIndex + 1].id); + }, + previous: () => { + const currentIndex = s.steps.findIndex( + (step) => step.id === activeStep(), + ); + if (currentIndex <= 0) { + throw new Error("No previous step available"); + } + setActiveStep(s.steps[currentIndex - 1].id); + }, + hasPrevious: () => { + const currentIndex = s.steps.findIndex( + (step) => step.id === activeStep(), + ); + return currentIndex > 0; + }, + hasNext: () => { + const currentIndex = s.steps.findIndex( + (step) => step.id === activeStep(), + ); + return currentIndex >= 0 && currentIndex < s.steps.length - 1; + }, + }; +} + +type StepperReturn = { + activeStep: Accessor; + setActiveStep: Setter; + currentStep: () => T[number]; + next: () => void; + previous: () => void; + hasPrevious: () => boolean; + hasNext: () => boolean; +}; + +function createStepperContext() { + return createContext>(); +} +const StepperContext = createStepperContext(); + +export function StepperProvider(props: { + stepper: StepperReturn; + children: JSX.Element; +}) { + return ( + // @ts-expect-error: I dont have time for this shit + + {props.children} + + ); +} + +export function useStepper() { + const ctx = useContext(StepperContext); + if (!ctx) throw new Error("useStepper must be used inside StepperProvider"); + return ctx; +} + +type InstallForm = { + data_from_step_1: string; + data_from_step_2?: string; + data_from_step_3?: string; +}; + +const NextButton = () => { + const stepSignal = useStepper(); + return ( + + ); +}; + +const InstallStepper = () => { + const stepSignal = useStepper(); + + const [formStore, { Form, Field, FieldArray }] = createForm({}); + + const handleSubmit: SubmitHandler = (values, event) => { + console.log("Installation started (submit)", values); + stepSignal.setActiveStep("install:progress"); + }; + return ( +
+
+ +
+
+ ); +}; + +export const BackButton = () => { + const stepSignal = useStepper(); + return ( + + ); +}; + +export interface InstallModalProps { + machineName: string; +} + +const InitialChoice = () => { + const stepSignal = useStepper(); + return ( +
+

Welcome to the installation wizard!

+

Please choose how you want to install your machine.

+ + + + +
+ ); +}; + +const CreateIso = () => { + const [formStore, { Form, Field }] = createForm({}); + const stepSignal = useStepper(); + + // TODO: push values to the parent form Store + const handleSubmit: SubmitHandler<{}> = (values, event) => { + console.log("ISO creation submitted", values); + // Here you would typically trigger the ISO creation process + stepSignal.next(); + }; + + return ( +
+

Creating an ISO 1nstaller

+ +
+ + +
+
+ ); +}; + +export const InstallModal = (props: InstallModalProps) => { + const stepper = createStepper( + { + steps: [ + { + id: "init", + title: InstallHeader, + content: InitialChoice, + }, + { + id: "create:iso-0", + title: InstallHeader, + content: CreateIso, + }, + { + id: "install:machine-0", + title: InstallHeader, + content: () => ( +
+ Enter the targetHost + +
+ ), + }, + { + id: "install:confirm", + title: InstallHeader, + content: () => ( +
+ Confirm the installation of {props.machineName} + +
+ ), + }, + { + id: "install:progress", + title: InstallHeader, + content: () => ( +
+

Installation in progress...

+

Please wait while we set up your machine.

+
+ ), + }, + ], + }, + { initialStep: "init" }, + ); + + return ( + + { + console.log("Install aborted"); + }} + header={() => { + const HeaderComponent = stepper.currentStep()?.title; + return ( + + ); + }} + > + {(ctx) => } + + + ); +};