ui/machines: hook up create machine with scene workflow
This commit is contained in:
@@ -24,16 +24,11 @@ import {
|
||||
useClanListQuery,
|
||||
useMachinesQuery,
|
||||
} from "@/src/hooks/queries";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
||||
import { produce } from "solid-js/store";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { Splash } from "@/src/scene/splash";
|
||||
import cx from "classnames";
|
||||
import styles from "./Clan.module.css";
|
||||
import { Modal } from "@/src/components/Modal/Modal";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||
import { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||
@@ -43,6 +38,7 @@ import {
|
||||
} from "@/src/workflows/Service/Service";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import toast from "solid-toast";
|
||||
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||
|
||||
interface ClanContextProps {
|
||||
clanURI: string;
|
||||
@@ -134,56 +130,6 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface CreateFormValues extends FieldValues {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MockProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (formValues: CreateFormValues) => void;
|
||||
}
|
||||
|
||||
const MockCreateMachine = (props: MockProps) => {
|
||||
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={true}
|
||||
onClose={() => {
|
||||
reset(form);
|
||||
props.onClose();
|
||||
}}
|
||||
class={cx(styles.createModal)}
|
||||
title="Create Machine"
|
||||
>
|
||||
<Form class="flex flex-col" onSubmit={props.onSubmit}>
|
||||
<Field name="name">
|
||||
{(field, props) => (
|
||||
<>
|
||||
<TextInput
|
||||
{...field}
|
||||
label="Name"
|
||||
size="s"
|
||||
required={true}
|
||||
input={{ ...props, placeholder: "name", autofocus: true }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="mt-4 flex w-full items-center justify-end gap-4">
|
||||
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const ClanSceneController = (props: RouteSectionProps) => {
|
||||
const ctx = useContext(ClanContext);
|
||||
if (!ctx) {
|
||||
@@ -194,7 +140,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
|
||||
const [showService, setShowService] = createSignal(false);
|
||||
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
const [showCreate, setShowCreate] = createSignal(false);
|
||||
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||
resolve: ({ id }: { id: string }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
@@ -202,45 +148,11 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
|
||||
const onCreate = async (): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setShowModal(true);
|
||||
setShowCreate(true);
|
||||
setCurrentPromise({ resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const onAddService = async (): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setShowService((v) => !v);
|
||||
console.log("setting current promise");
|
||||
setCurrentPromise({ resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const sendCreate = async (values: CreateFormValues) => {
|
||||
const api = callApi("create_machine", {
|
||||
opts: {
|
||||
clan_dir: {
|
||||
identifier: ctx.clanURI,
|
||||
},
|
||||
machine: {
|
||||
name: values.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await api.result;
|
||||
if (res.status === "error") {
|
||||
// TODO: Handle displaying errors
|
||||
console.error("Error creating machine:");
|
||||
|
||||
// Important: rejects the promise
|
||||
throw new Error(res.errors[0].message);
|
||||
}
|
||||
|
||||
// trigger a refetch of the machines query
|
||||
ctx.machinesQuery.refetch();
|
||||
|
||||
return { id: values.name };
|
||||
};
|
||||
|
||||
const [loadingError, setLoadingError] = createSignal<
|
||||
{ title: string; description: string } | undefined
|
||||
>();
|
||||
@@ -312,8 +224,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
console.error("Error creating service instance", result.errors);
|
||||
}
|
||||
toast.success("Created");
|
||||
//
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
setShowService(false);
|
||||
};
|
||||
|
||||
@@ -322,7 +232,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
if (mode === "service") {
|
||||
setShowService(true);
|
||||
} else {
|
||||
// todo: request close instead of force close
|
||||
// TODO: request soft close instead of forced close
|
||||
setShowService(false);
|
||||
}
|
||||
}),
|
||||
@@ -333,22 +243,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
<Show when={loadingError()}>
|
||||
<ListClansModal error={loadingError()} />
|
||||
</Show>
|
||||
<Show when={showModal()}>
|
||||
<MockCreateMachine
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
currentPromise()?.reject(new Error("User cancelled"));
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const result = await sendCreate(values);
|
||||
currentPromise()?.resolve(result);
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
currentPromise()?.reject(err);
|
||||
setShowModal(false);
|
||||
<Show when={showCreate()}>
|
||||
<AddMachine
|
||||
onCreated={async (id) => {
|
||||
const promise = currentPromise();
|
||||
if (promise) {
|
||||
await ctx.machinesQuery.refetch();
|
||||
promise.resolve({ id });
|
||||
setCurrentPromise(null);
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowCreate(false);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||
import { InstallModal } from "@/src/workflows/InstallMachine/InstallMachine";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
MemoryRouter,
|
||||
@@ -8,8 +7,12 @@ import {
|
||||
} from "@solidjs/router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
|
||||
import { ApiCall, OperationNames, OperationResponse, SuccessQuery } from "@/src/hooks/api";
|
||||
|
||||
import {
|
||||
ApiCall,
|
||||
OperationNames,
|
||||
OperationResponse,
|
||||
SuccessQuery,
|
||||
} from "@/src/hooks/api";
|
||||
|
||||
type ResultDataMap = {
|
||||
[K in OperationNames]: SuccessQuery<K>["data"];
|
||||
@@ -21,16 +24,16 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||
): ApiCall<K> => {
|
||||
// TODO: Make this configurable for every story
|
||||
const resultData: Partial<ResultDataMap> = {
|
||||
"list_machines": {
|
||||
list_machines: {
|
||||
pandora: {
|
||||
name: "pandora"
|
||||
name: "pandora",
|
||||
},
|
||||
enceladus: {
|
||||
name: "enceladus"
|
||||
name: "enceladus",
|
||||
},
|
||||
dione: {
|
||||
name: "dione"
|
||||
}
|
||||
name: "dione",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,7 +52,6 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const meta: Meta<typeof AddMachine> = {
|
||||
title: "workflows/add-machine",
|
||||
component: AddMachine,
|
||||
@@ -88,30 +90,30 @@ const meta: Meta<typeof AddMachine> = {
|
||||
);
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AddMachine>;
|
||||
|
||||
export const General: Story = {
|
||||
args: {}
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Host: Story = {
|
||||
args: {
|
||||
initialStep: "host"
|
||||
}
|
||||
}
|
||||
initialStep: "host",
|
||||
},
|
||||
};
|
||||
|
||||
export const Tags: Story = {
|
||||
args: {
|
||||
initialStep: "tags"
|
||||
}
|
||||
}
|
||||
initialStep: "tags",
|
||||
},
|
||||
};
|
||||
|
||||
export const Progress: Story = {
|
||||
args: {
|
||||
initialStep: "progress"
|
||||
}
|
||||
}
|
||||
initialStep: "progress",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
StepperProvider,
|
||||
useStepper,
|
||||
} from "@/src/hooks/stepper";
|
||||
import { StepGeneral } from "@/src/workflows/AddMachine/StepGeneral";
|
||||
import {
|
||||
GeneralForm,
|
||||
StepGeneral,
|
||||
} from "@/src/workflows/AddMachine/StepGeneral";
|
||||
import { Modal } from "@/src/components/Modal/Modal";
|
||||
import cx from "classnames";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
@@ -12,8 +15,7 @@ import { Show } from "solid-js";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { StepHost } from "@/src/workflows/AddMachine/StepHost";
|
||||
import { StepTags } from "@/src/workflows/AddMachine/StepTags";
|
||||
import { ApiCall } from "@/src/hooks/api";
|
||||
import { StepProgress } from "@/src/workflows/AddMachine/StepProgress";
|
||||
import { StepProgress } from "./StepProgress";
|
||||
|
||||
interface AddMachineStepperProps {
|
||||
onDone: () => void;
|
||||
@@ -32,21 +34,20 @@ const AddMachineStepper = (props: AddMachineStepperProps) => {
|
||||
|
||||
export interface AddMachineProps {
|
||||
onClose: () => void;
|
||||
onCreated: (id: string) => void;
|
||||
initialStep?: AddMachineSteps[number]["id"];
|
||||
}
|
||||
|
||||
export interface AddMachineStoreType {
|
||||
general?: {
|
||||
name: string;
|
||||
description: string;
|
||||
machineClass: "nixos" | "darwin";
|
||||
};
|
||||
deploy?: {
|
||||
general: GeneralForm;
|
||||
deploy: {
|
||||
targetHost: string;
|
||||
};
|
||||
tags?: {
|
||||
tags: {
|
||||
tags: string[];
|
||||
};
|
||||
onCreated: (id: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const steps = defineSteps([
|
||||
@@ -67,6 +68,7 @@ const steps = defineSteps([
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
title: "Creating...",
|
||||
content: StepProgress,
|
||||
isSplash: true,
|
||||
},
|
||||
@@ -79,11 +81,14 @@ export const AddMachine = (props: AddMachineProps) => {
|
||||
{
|
||||
steps,
|
||||
},
|
||||
{ initialStep: props.initialStep || "general" },
|
||||
{
|
||||
initialStep: props.initialStep || "general",
|
||||
initialStoreData: { onCreated: props.onCreated },
|
||||
},
|
||||
);
|
||||
|
||||
const MetaHeader = () => {
|
||||
const title = stepper.currentStep()?.title;
|
||||
const title = stepper.currentStep().title;
|
||||
return (
|
||||
<Show when={title}>
|
||||
<Typography
|
||||
@@ -113,8 +118,7 @@ export const AddMachine = (props: AddMachineProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StepperProvider
|
||||
stepper={stepper}>
|
||||
<StepperProvider stepper={stepper}>
|
||||
<Modal
|
||||
class={cx("w-screen", sizeClasses())}
|
||||
title="Add Machine"
|
||||
@@ -125,7 +129,7 @@ export const AddMachine = (props: AddMachineProps) => {
|
||||
// @ts-expect-error some steps might not have
|
||||
disablePadding={stepper.currentStep()?.isSplash}
|
||||
>
|
||||
<AddMachineStepper onDone={() => props.onClose} />
|
||||
<AddMachineStepper onDone={() => props.onClose()} />
|
||||
</Modal>
|
||||
</StepperProvider>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextButton, StepLayout } from "@/src/workflows/Steps";
|
||||
import * as v from "valibot";
|
||||
import { value } from "valibot";
|
||||
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||
import {
|
||||
clearError,
|
||||
createForm,
|
||||
FieldValues,
|
||||
getError,
|
||||
getErrors,
|
||||
setError,
|
||||
@@ -43,7 +43,11 @@ const GeneralSchema = v.object({
|
||||
machineClass: v.pipe(v.string(), v.nonEmpty()),
|
||||
});
|
||||
|
||||
type GeneralForm = v.InferInput<typeof GeneralSchema>;
|
||||
export interface GeneralForm extends FieldValues {
|
||||
machineClass: "nixos" | "darwin";
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const StepGeneral = () => {
|
||||
const stepSignal = useStepper<AddMachineSteps>();
|
||||
@@ -58,17 +62,21 @@ export const StepGeneral = () => {
|
||||
}
|
||||
|
||||
return Object.keys(machines.data || {});
|
||||
}
|
||||
};
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<GeneralForm>({
|
||||
validate: valiForm(GeneralSchema),
|
||||
initialValues: { machineClass: "nixos", ...store.general },
|
||||
initialValues: { ...store.general, machineClass: "nixos" },
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<GeneralForm> = (values, event) => {
|
||||
if (machineNames().includes(values.name)) {
|
||||
setError(formStore, "name", `A machine named '${values.name}' already exists. Please choose a different one.`)
|
||||
return
|
||||
setError(
|
||||
formStore,
|
||||
"name",
|
||||
`A machine named '${values.name}' already exists. Please choose a different one.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
clearError(formStore, "name");
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
} from "@/src/workflows/AddMachine/AddMachine";
|
||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { Divider } from "@/src/components/Divider/Divider";
|
||||
import { TextArea } from "@/src/components/Form/TextArea";
|
||||
|
||||
const HostSchema = v.object({
|
||||
targetHost: v.pipe(v.string("Name must be a string")),
|
||||
@@ -52,7 +50,6 @@ export const StepHost = () => {
|
||||
{...field}
|
||||
value={field.value}
|
||||
label="Target"
|
||||
required
|
||||
orientation="horizontal"
|
||||
input={{
|
||||
...input,
|
||||
|
||||
@@ -5,11 +5,8 @@ import {
|
||||
} from "@/src/workflows/AddMachine/AddMachine";
|
||||
import { Loader } from "@/src/components/Loader/Loader";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { createSignal, onMount, Show } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
import { Alert } from "@/src/components/Alert/Alert";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { useClanURI } from "@/src/hooks/clan";
|
||||
|
||||
export interface StepProgressProps {
|
||||
onDone: () => void;
|
||||
@@ -19,49 +16,24 @@ export const StepProgress = (props: StepProgressProps) => {
|
||||
const stepSignal = useStepper<AddMachineSteps>();
|
||||
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||
|
||||
const clanURI = useClanURI();
|
||||
const [error, setError] = createSignal<string | undefined>(undefined);
|
||||
|
||||
onMount(async () => {
|
||||
const call = callApi("create_machine", {
|
||||
opts: {
|
||||
clan_dir: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
machine: {
|
||||
...store.general,
|
||||
...store.deploy,
|
||||
...store.tags,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await call.result;
|
||||
|
||||
if (result.status == "error") {
|
||||
setError(result.errors[0].message);
|
||||
}
|
||||
|
||||
if (result.status == "success") {
|
||||
console.log("Machine creation was successful");
|
||||
props.onDone();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center justify-center gap-2.5 px-6 pb-7 pt-4">
|
||||
<Show
|
||||
when={error()}
|
||||
when={store.error}
|
||||
fallback={
|
||||
<>
|
||||
<Loader class="size-8" />
|
||||
<Typography hierarchy="body" size="s" weight="medium" family="mono">
|
||||
Brian is being created
|
||||
{store.general?.name} is being created
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Alert type="error" title="There was an error" description={error()} />
|
||||
<Alert
|
||||
type="error"
|
||||
title="There was an error"
|
||||
description={store.error}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BackButton, NextButton, StepLayout } from "@/src/workflows/Steps";
|
||||
import { BackButton, StepLayout } from "@/src/workflows/Steps";
|
||||
import * as v from "valibot";
|
||||
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid";
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import { useClanURI } from "@/src/hooks/clan";
|
||||
|
||||
const TagsSchema = v.object({
|
||||
tags: v.array(v.string()),
|
||||
@@ -16,7 +18,7 @@ const TagsSchema = v.object({
|
||||
|
||||
type TagsForm = v.InferInput<typeof TagsSchema>;
|
||||
|
||||
export const StepTags = () => {
|
||||
export const StepTags = (props: { onDone: () => void }) => {
|
||||
const stepSignal = useStepper<AddMachineSteps>();
|
||||
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||
|
||||
@@ -25,13 +27,44 @@ export const StepTags = () => {
|
||||
initialValues: store.tags,
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<TagsForm> = (values, event) => {
|
||||
const apiClient = useApiClient();
|
||||
const clanURI = useClanURI();
|
||||
|
||||
const handleSubmit: SubmitHandler<TagsForm> = async (values, event) => {
|
||||
set("tags", (s) => ({
|
||||
...s,
|
||||
...values,
|
||||
}));
|
||||
|
||||
const call = apiClient.fetch("create_machine", {
|
||||
opts: {
|
||||
clan_dir: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
machine: {
|
||||
...store.general,
|
||||
...store.tags,
|
||||
deploy: store.deploy,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
stepSignal.next();
|
||||
|
||||
const result = await call.result;
|
||||
|
||||
if (result.status == "error") {
|
||||
// setError(result.errors[0].message);
|
||||
}
|
||||
|
||||
if (result.status == "success") {
|
||||
console.log("Machine creation was successful");
|
||||
if (store.general) {
|
||||
store.onCreated(store.general.name);
|
||||
}
|
||||
}
|
||||
console.log("Done creating machine");
|
||||
props.onDone();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user