add: dynamic schema form with pure/impure seperation

This commit is contained in:
Johannes Kirschbauer
2023-09-03 14:15:10 +02:00
parent 10c4d26b58
commit 598dd776ec
9 changed files with 439 additions and 59 deletions

View File

@@ -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 />;
} }

View 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>
),
},
}}
/>
);
}

View 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>
);
}

View 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>;

View 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",
};