add: dynamic schema form with pure/impure seperation
This commit is contained in:
@@ -1,64 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
import { CreateMachineForm } from "@/components/createMachineForm";
|
||||||
|
|
||||||
import { JSONSchema7 } from "json-schema";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { schema } from "./schema";
|
|
||||||
import Form from "@rjsf/mui";
|
|
||||||
import validator from "@rjsf/validator-ajv8";
|
|
||||||
import { Button } from "@mui/material";
|
|
||||||
|
|
||||||
interface CreateMachineFormProps {
|
|
||||||
schema: JSONSchema7;
|
|
||||||
initialValues: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValues = Object.entries(schema.properties || {}).reduce(
|
|
||||||
(acc, [key, value]) => {
|
|
||||||
/*@ts-ignore*/
|
|
||||||
const init: any = value?.default;
|
|
||||||
if (init) {
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[key]: init,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
function CreateMachineForm(props: CreateMachineFormProps) {
|
|
||||||
const { schema, initialValues } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
acceptcharset="utf-8"
|
|
||||||
// @ts-ignore
|
|
||||||
extraErrors={{
|
|
||||||
__errors: ["Global error; Server said no"],
|
|
||||||
// @ts-ignore
|
|
||||||
name: {
|
|
||||||
__errors: ["Name is already in use"],
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
schema={schema}
|
|
||||||
validator={validator}
|
|
||||||
liveValidate={true}
|
|
||||||
templates={{
|
|
||||||
ButtonTemplates: {
|
|
||||||
SubmitButton: (props) => (
|
|
||||||
<Button type="submit" variant="contained" color="secondary">
|
|
||||||
Create Machine
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CreateMachine() {
|
export default function CreateMachine() {
|
||||||
return <CreateMachineForm schema={schema} initialValues={defaultValues} />;
|
return <CreateMachineForm />;
|
||||||
}
|
}
|
||||||
|
|||||||
165
pkgs/ui/src/components/createMachineForm/customConfig.tsx
Normal file
165
pkgs/ui/src/components/createMachineForm/customConfig.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
import { useGetMachineSchema } from "@/api/default/default";
|
||||||
|
import { Check, Error } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
LinearProgress,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { IChangeEvent, FormProps } from "@rjsf/core";
|
||||||
|
import { Form } from "@rjsf/mui";
|
||||||
|
import validator from "@rjsf/validator-ajv8";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { JSONSchema7 } from "json-schema";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { FormStepContentProps } from "./interfaces";
|
||||||
|
import {
|
||||||
|
ErrorListProps,
|
||||||
|
FormContextType,
|
||||||
|
RJSFSchema,
|
||||||
|
StrictRJSFSchema,
|
||||||
|
TranslatableString,
|
||||||
|
} from "@rjsf/utils";
|
||||||
|
|
||||||
|
interface PureCustomConfigProps extends FormStepContentProps {
|
||||||
|
schema: JSONSchema7;
|
||||||
|
initialValues: any;
|
||||||
|
}
|
||||||
|
export function CustomConfig(props: FormStepContentProps) {
|
||||||
|
const { formHooks, handleNext } = props;
|
||||||
|
const { data, isLoading, error } = useGetMachineSchema("mama");
|
||||||
|
const schema = useMemo(() => {
|
||||||
|
if (!isLoading && !error?.message && data?.data) {
|
||||||
|
return data?.data.schema;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}, [data, isLoading, error]);
|
||||||
|
|
||||||
|
const initialValues = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(schema?.properties || {}).reduce((acc, [key, value]) => {
|
||||||
|
/*@ts-ignore*/
|
||||||
|
const init: any = value?.default;
|
||||||
|
if (init) {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[key]: init,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
[schema],
|
||||||
|
);
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<LinearProgress variant="indeterminate" />
|
||||||
|
) : error?.message ? (
|
||||||
|
<div>{error?.message}</div>
|
||||||
|
) : (
|
||||||
|
<PureCustomConfig
|
||||||
|
formHooks={formHooks}
|
||||||
|
initialValues={initialValues}
|
||||||
|
schema={schema}
|
||||||
|
handleNext={handleNext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorList<
|
||||||
|
T = any,
|
||||||
|
S extends StrictRJSFSchema = RJSFSchema,
|
||||||
|
F extends FormContextType = any,
|
||||||
|
>({ errors, registry }: ErrorListProps<T, S, F>) {
|
||||||
|
const { translateString } = registry;
|
||||||
|
return (
|
||||||
|
<Paper elevation={0}>
|
||||||
|
<Box mb={2} p={2}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
{translateString(TranslatableString.ErrorsLabel)}
|
||||||
|
</Typography>
|
||||||
|
<List dense={true}>
|
||||||
|
{errors.map((error, i: number) => {
|
||||||
|
return (
|
||||||
|
<ListItem key={i}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Error color="error" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={error.stack} />
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PureCustomConfig(props: PureCustomConfigProps) {
|
||||||
|
const { schema, initialValues, formHooks, handleNext } = props;
|
||||||
|
const { setValue, watch } = formHooks;
|
||||||
|
|
||||||
|
console.log({ schema });
|
||||||
|
|
||||||
|
const configData = watch("config") as IChangeEvent<any>;
|
||||||
|
|
||||||
|
console.log({ configData });
|
||||||
|
|
||||||
|
const setConfig = (data: IChangeEvent<any>) => {
|
||||||
|
console.log({ data });
|
||||||
|
setValue("config", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formRef = useRef<any>();
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const isValid: boolean = formRef?.current?.validateForm();
|
||||||
|
console.log({ isValid }, formRef.current);
|
||||||
|
if (!isValid) {
|
||||||
|
formHooks.setError("config", {
|
||||||
|
message: "invalid config",
|
||||||
|
});
|
||||||
|
toast.error(
|
||||||
|
"Configuration is invalid. Please check the highlighted fields for details.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
formHooks.clearErrors("config");
|
||||||
|
toast.success("Config seems valid");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
ref={formRef}
|
||||||
|
onChange={setConfig}
|
||||||
|
formData={configData.formData}
|
||||||
|
acceptcharset="utf-8"
|
||||||
|
schema={schema}
|
||||||
|
validator={validator}
|
||||||
|
liveValidate={true}
|
||||||
|
templates={{
|
||||||
|
// ObjectFieldTemplate:
|
||||||
|
ErrorListTemplate: ErrorList,
|
||||||
|
ButtonTemplates: {
|
||||||
|
SubmitButton: (props) => (
|
||||||
|
<div className="flex w-full items-center justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={validate}
|
||||||
|
startIcon={<Check />}
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
Validate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
pkgs/ui/src/components/createMachineForm/index.tsx
Normal file
160
pkgs/ui/src/components/createMachineForm/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
MobileStepper,
|
||||||
|
Step,
|
||||||
|
StepLabel,
|
||||||
|
Stepper,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import React, { ReactNode, useState } from "react";
|
||||||
|
import { useForm, UseFormReturn } from "react-hook-form";
|
||||||
|
import { CustomConfig } from "./customConfig";
|
||||||
|
import { CreateMachineForm, FormStep } from "./interfaces";
|
||||||
|
|
||||||
|
const SC = (props: { children: ReactNode }) => {
|
||||||
|
return <>{props.children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateMachineForm() {
|
||||||
|
const formHooks = useForm<CreateMachineForm>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { handleSubmit, control, watch, reset, formState } = formHooks;
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
const [activeStep, setActiveStep] = useState<number>(0);
|
||||||
|
|
||||||
|
const steps: FormStep[] = [
|
||||||
|
{
|
||||||
|
id: "template",
|
||||||
|
label: "Template",
|
||||||
|
content: <div></div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "modules",
|
||||||
|
label: "Modules",
|
||||||
|
content: <div></div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "config",
|
||||||
|
label: "Customize",
|
||||||
|
content: <CustomConfig formHooks={formHooks} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "save",
|
||||||
|
label: "Save",
|
||||||
|
content: <div></div>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (activeStep < steps.length - 1) {
|
||||||
|
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (activeStep > 0) {
|
||||||
|
setActiveStep((prevActiveStep) => prevActiveStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setActiveStep(0);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
const currentStep = steps.at(activeStep);
|
||||||
|
|
||||||
|
async function onSubmit(data: any) {
|
||||||
|
console.log({ data }, "Aggregated Data; creating machine from");
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackButton = () => (
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
disabled={activeStep === 0}
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NextButton = () => (
|
||||||
|
<>
|
||||||
|
{activeStep !== steps.length - 1 && (
|
||||||
|
<Button
|
||||||
|
disabled={!formHooks.formState.isValid}
|
||||||
|
onClick={handleNext}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{activeStep <= steps.length - 1 && "Next"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{activeStep === steps.length - 1 && (
|
||||||
|
<Button color="secondary" onClick={handleReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
{isMobile && (
|
||||||
|
<MobileStepper
|
||||||
|
activeStep={activeStep}
|
||||||
|
color="secondary"
|
||||||
|
backButton={<BackButton />}
|
||||||
|
nextButton={<NextButton />}
|
||||||
|
steps={steps.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isMobile && (
|
||||||
|
<Stepper activeStep={activeStep} color="secondary">
|
||||||
|
{steps.map(({ label }, index) => {
|
||||||
|
const stepProps: { completed?: boolean } = {};
|
||||||
|
const labelProps: {
|
||||||
|
optional?: React.ReactNode;
|
||||||
|
} = {};
|
||||||
|
return (
|
||||||
|
<Step
|
||||||
|
sx={{
|
||||||
|
".MuiStepIcon-root.Mui-active": {
|
||||||
|
color: "secondary.main",
|
||||||
|
},
|
||||||
|
".MuiStepIcon-root.Mui-completed": {
|
||||||
|
color: "secondary.main",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
key={label}
|
||||||
|
{...stepProps}
|
||||||
|
>
|
||||||
|
<StepLabel {...labelProps}>{label}</StepLabel>
|
||||||
|
</Step>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stepper>
|
||||||
|
)}
|
||||||
|
{/* <CustomConfig formHooks={formHooks} /> */}
|
||||||
|
{/* The step Content */}
|
||||||
|
{currentStep && currentStep.content}
|
||||||
|
|
||||||
|
{/* Desktop step controls */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
|
||||||
|
<BackButton />
|
||||||
|
<Box sx={{ flex: "1 1 auto" }} />
|
||||||
|
<NextButton />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
pkgs/ui/src/components/createMachineForm/interfaces.ts
Normal file
24
pkgs/ui/src/components/createMachineForm/interfaces.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ReactElement, ReactNode } from "react";
|
||||||
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
|
export type StepId = "template" | "modules" | "config" | "save";
|
||||||
|
|
||||||
|
export type CreateMachineForm = {
|
||||||
|
name: string;
|
||||||
|
config: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormHooks = UseFormReturn<CreateMachineForm>;
|
||||||
|
|
||||||
|
export type FormStep = {
|
||||||
|
id: StepId;
|
||||||
|
label: string;
|
||||||
|
content: FormStepContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FormStepContentProps {
|
||||||
|
formHooks: FormHooks;
|
||||||
|
handleNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormStepContent = ReactElement<FormStepContentProps>;
|
||||||
88
pkgs/ui/src/data/_schema.ts
Normal file
88
pkgs/ui/src/data/_schema.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { RJSFSchema } from "@rjsf/utils";
|
||||||
|
export const schema: RJSFSchema = {
|
||||||
|
properties: {
|
||||||
|
bloatware: {
|
||||||
|
properties: {
|
||||||
|
age: {
|
||||||
|
default: 42,
|
||||||
|
description: "The age of the user",
|
||||||
|
type: "integer",
|
||||||
|
},
|
||||||
|
isAdmin: {
|
||||||
|
default: false,
|
||||||
|
description: "Is the user an admin?",
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
kernelModules: {
|
||||||
|
default: ["nvme", "xhci_pci", "ahci"],
|
||||||
|
description: "A list of enabled kernel modules",
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
type: "array",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
default: "John Doe",
|
||||||
|
description: "The name of the user",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
properties: {
|
||||||
|
opt: {
|
||||||
|
default: "foo",
|
||||||
|
description: "A submodule option",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
},
|
||||||
|
userIds: {
|
||||||
|
additionalProperties: {
|
||||||
|
type: "integer",
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
albrecht: 3,
|
||||||
|
horst: 1,
|
||||||
|
peter: 2,
|
||||||
|
},
|
||||||
|
description: "Some attributes",
|
||||||
|
type: "object",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
},
|
||||||
|
networking: {
|
||||||
|
properties: {
|
||||||
|
zerotier: {
|
||||||
|
properties: {
|
||||||
|
controller: {
|
||||||
|
properties: {
|
||||||
|
enable: {
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
"Whether to enable turn this machine into the networkcontroller.",
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
"everyone can join a public network without having the administrator to accept\n",
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
},
|
||||||
|
networkId: {
|
||||||
|
description: "zerotier networking id\n",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["networkId"],
|
||||||
|
type: "object",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user