SmartForm

This commit is contained in:
Stan Wohlwend 2024-05-18 10:51:54 +02:00
parent 92a4a18632
commit c4a16ad2a9
8 changed files with 189 additions and 27 deletions

View File

@ -6,7 +6,7 @@ import EnvKeys from "@/components/env-keys";
import { ApiKeySetFirstView } from "@stackframe/stack";
import { PageLayout } from "../page-layout";
import { ApiKeyTable } from "@/components/data-table/api-key-table";
import { FormDialog } from "@/components/form-dialog";
import { FormDialog, SmartFormDialog } from "@/components/form-dialog";
import { InputField, SelectField } from "@/components/form-fields";
import * as yup from "yup";
import { ActionDialog } from "@/components/action-dialog";
@ -57,8 +57,12 @@ const expiresInOptions = {
} as const;
const formSchema = yup.object({
description: yup.string().required(),
expiresIn: yup.string().required(),
description: yup.string().required().label("Description"),
expiresIn: yup.string().default(neverInMs.toString()).label("Expires in").meta({
stackFormFieldRender: (props) => (
<SelectField {...props} options={Object.entries(expiresInOptions).map(([value, label]) => ({ value, label }))} />
)
}),
});
function CreateDialog(props: {
@ -68,19 +72,12 @@ function CreateDialog(props: {
}) {
const stackAdminApp = useAdminApp();
return <FormDialog
return <SmartFormDialog
open={props.open}
onOpenChange={props.onOpenChange}
title="Create API Key"
formSchema={formSchema}
defaultValues={{ expiresIn: neverInMs.toString() }}
okButton={{ label: "Create" }}
render={(form) => (
<>
<InputField control={form.control} label="Description" name="description" />
<SelectField control={form.control} label="Expires in" name="expiresIn" options={Object.entries(expiresInOptions).map(([value, label]) => ({ value, label }))} />
</>
)}
onSubmit={async (values) => {
const expiresIn = parseInt(values.expiresIn);
const newKey = await stackAdminApp.createApiKeySet({

View File

@ -12,7 +12,7 @@ import { Label } from "./ui/label";
export type ActionDialogProps = {
trigger?: React.ReactNode,
open?: boolean,
onClose?: () => void | Promise<void>,
onClose?: () => void,
onOpenChange?: (open: boolean) => void,
titleIcon?: LucideIcon,
title: boolean | React.ReactNode,
@ -42,20 +42,21 @@ export function ActionDialog(props: ActionDialogProps) {
const open = props.open ?? openState;
const [confirmed, setConfirmed] = React.useState(false);
const confirmId = useId();
const [invalidationCount, setInvalidationCount] = React.useState(0);
const onOpenChange = (open: boolean) => {
if (!open && props.onClose) {
runAsynchronously(props.onClose());
}
if (!open) {
props.onClose?.();
setConfirmed(false);
} else {
setInvalidationCount(invalidationCount + 1);
}
setOpenState(open);
props.onOpenChange?.(open);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange} key={invalidationCount}>
{props.trigger && <DialogTrigger asChild>
{props.trigger}
</DialogTrigger>}

View File

@ -6,7 +6,47 @@ import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"
import { yupResolver } from "@hookform/resolvers/yup";
import { FieldValues, useForm } from "react-hook-form";
import { Form } from "@/components/ui/form";
import React from "react";
import { SmartForm } from "./smart-form";
export function SmartFormDialog<S extends yup.ObjectSchema<any, any, any, any>>(
props: Omit<ActionDialogProps, 'children'> & {
formSchema: S,
onSubmit: (values: yup.InferType<S>) => Promise<void | 'prevent-close'> | void | 'prevent-close',
},
) {
const formId = `${useId()}-form`;
const [submitting, setSubmitting] = useState(false);
const [openState, setOpenState] = useState(false);
const handleSubmit = async (values: yup.InferType<S>) => {
const res = await props.onSubmit(values);
if (res !== 'prevent-close') {
setOpenState(false);
props.onOpenChange?.(false);
props.onClose?.();
}
};
return (
<ActionDialog
{...props}
open={props.open ?? openState}
onOpenChange={(open) => { setOpenState(open); props.onOpenChange?.(open); }}
okButton={{
onClick: async () => "prevent-close",
...(typeof props.okButton === "boolean" ? {} : props.okButton),
props: {
form: formId,
type: "submit",
loading: submitting,
...((typeof props.okButton === "boolean") ? {} : props.okButton?.props)
},
}}
>
<SmartForm formSchema={props.formSchema} onSubmit={handleSubmit} onChangeIsSubmitting={setSubmitting} formId={formId} />
</ActionDialog>
);
}
export function FormDialog<F extends FieldValues>(
props: Omit<ActionDialogProps, 'children'> & {
@ -33,8 +73,8 @@ export function FormDialog<F extends FieldValues>(
form.reset();
if (result !== 'prevent-close') {
setOpenState(false);
await props.onClose?.();
await props.onOpenChange?.(false);
props.onClose?.();
props.onOpenChange?.(false);
}
} finally {
setSubmitting(false);
@ -50,7 +90,7 @@ export function FormDialog<F extends FieldValues>(
{...props}
open={props.open ?? openState}
onOpenChange={(open) => { if(open) setOpenState(true); props.onOpenChange?.(open); }}
onClose={async () => { form.reset(); setOpenState(false); await props.onClose?.(); }}
onClose={() => { form.reset(); setOpenState(false); props.onClose?.(); }}
okButton={{
onClick: async () => "prevent-close",
...(typeof props.okButton == "boolean" ? {} : props.okButton),
@ -69,4 +109,4 @@ export function FormDialog<F extends FieldValues>(
</Form>
</ActionDialog>
);
}
}

View File

@ -23,6 +23,7 @@ export function InputField<F extends FieldValues>(props: {
label: string,
placeholder?: string,
required?: boolean,
disabled?: boolean,
}) {
return (
<FormField
@ -33,7 +34,7 @@ export function InputField<F extends FieldValues>(props: {
<label className="block">
<FieldLabel required={props.required}>{props.label}</FieldLabel>
<FormControl>
<Input {...field} placeholder={props.placeholder} />
<Input {...field} placeholder={props.placeholder} disabled={props.disabled} />
</FormControl>
<FormMessage />
</label>
@ -49,6 +50,7 @@ export function SwitchField<F extends FieldValues>(props: {
label: string,
required?: boolean,
noCard?: boolean,
disabled?: boolean,
}) {
return (
<FormField
@ -65,6 +67,7 @@ export function SwitchField<F extends FieldValues>(props: {
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={props.disabled}
/>
</FormControl>
</label>
@ -80,6 +83,7 @@ export function SmallSwitchField<F extends FieldValues>(props: {
name: Path<F>,
label: string,
required?: boolean,
disabled?: boolean,
}) {
return (
<FormField
@ -93,6 +97,7 @@ export function SmallSwitchField<F extends FieldValues>(props: {
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={props.disabled}
/>
</FormControl>
</label>
@ -110,6 +115,7 @@ export function SwitchListField<F extends FieldValues>(props: {
label: string,
options: { value: string, label: string }[],
required?: boolean,
disabled?: boolean,
}) {
const Trigger = props.variant === "checkbox" ? Checkbox : Switch;
@ -134,6 +140,7 @@ export function SwitchListField<F extends FieldValues>(props: {
field.onChange(field.value.filter((v: any) => v !== provider.value));
}
}}
disabled={props.disabled}
/>
</FormControl>
<FormMessage />
@ -151,6 +158,7 @@ export function DateField<F extends FieldValues>(props: {
name: Path<F>,
label: string,
required?: boolean,
disabled?: boolean,
}) {
return (
<FormField
@ -168,6 +176,7 @@ export function DateField<F extends FieldValues>(props: {
"w-[240px] pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
disabled={props.disabled}
>
{field.value ? field.value.toLocaleDateString() : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
@ -180,6 +189,7 @@ export function DateField<F extends FieldValues>(props: {
selected={field.value}
onSelect={field.onChange}
initialFocus
disabled={props.disabled}
/>
</PopoverContent>
</Popover>
@ -197,6 +207,7 @@ export function SelectField<F extends FieldValues>(props: {
options: { value: string, label: string }[],
placeholder?: string,
required?: boolean,
disabled?: boolean,
}) {
return (
<FormField
@ -206,7 +217,7 @@ export function SelectField<F extends FieldValues>(props: {
<FormItem>
<FieldLabel required={props.required}>{props.label}</FieldLabel>
<FormControl>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={props.disabled}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder}/>
</SelectTrigger>

View File

@ -0,0 +1,100 @@
"use client";
import * as yup from "yup";
import { Form } from "./ui/form";
import { yupResolver } from "@hookform/resolvers/yup";
import { useForm } from "react-hook-form";
import React, { useCallback, useMemo, useState } from "react";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { DateField, InputField } from "./form-fields";
// Used for yup TS support
declare module 'yup' {
export interface CustomSchemaMetadata {
stackFormFieldRender?: (props: { control: ReturnType<typeof useForm>['control'], name: string, label: string, disabled: boolean }) => React.ReactNode,
}
}
export function SmartForm<S extends yup.ObjectSchema<any, any, any, any>>(props: {
formSchema: S,
onSubmit: (values: yup.InferType<S>) => Promise<void>,
formId?: string,
onChangeIsSubmitting?: (isSubmitting: boolean) => void,
}) {
const form = useForm({
resolver: yupResolver(props.formSchema),
defaultValues: props.formSchema.getDefault(),
mode: "onChange",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = useCallback(async (e: React.BaseSyntheticEvent) => {
props.onChangeIsSubmitting?.(true);
setIsSubmitting(true);
try {
await form.handleSubmit(async (values: yup.InferType<S>, e?: React.BaseSyntheticEvent) => {
e!.preventDefault();
await props.onSubmit(values);
form.reset();
})(e);
} finally {
props.onChangeIsSubmitting?.(false);
setIsSubmitting(false);
}
}, [props, form]);
const details = props.formSchema.describe();
return (
<Form {...form}>
<form onSubmit={(e) => runAsynchronously(handleSubmit(e))} id={props.formId} className="space-y-4">
{Object.entries(details.fields).map(([fieldId, field]) => (
<SmartFormField key={fieldId} id={fieldId} description={field} form={form} disabled={isSubmitting} />
))}
</form>
</Form>
);
};
SmartForm.displayName = 'SmartForm';
function SmartFormField(props: {
id: string,
description: yup.SchemaFieldDescription,
form: ReturnType<typeof useForm>,
disabled: boolean,
}) {
const usualProps = {
control: props.form.control,
name: props.id,
label: ("label" in props.description ? props.description.label : null) ?? props.id,
disabled: props.disabled,
required: !("optional" in props.description && props.description.optional) && (!("default" in props.description) || props.description.default === undefined),
};
if ("meta" in props.description) {
const meta = props.description.meta;
const stackFormFieldRender = meta?.stackFormFieldRender;
if (stackFormFieldRender) {
return stackFormFieldRender(usualProps);
}
}
if (props.description.type === 'ref') {
// don't render refs
return null;
}
if (!("oneOf" in props.description)) {
throw new StackAssertionError(`Unsupported yup field ${props.id}; can't create form automatically from lazy yup schema`);
}
switch (props.description.type) {
case 'string': {
return <InputField {...usualProps} />;
}
case 'date': {
return <DateField {...usualProps} />;
}
}
throw new StackAssertionError(`Unsupported yup field ${props.id}; can't create form automatically from schema of type ${JSON.stringify(props.description.type)}. Maybe you need to implement it, or add a stackFormFieldRender meta property to the schema.`);
}

View File

@ -57,7 +57,7 @@ const DialogBody = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("overflow-y-auto flex flex-col gap-4 w-[calc(100%+3rem)] -mx-6 px-6 my-4", className)} {...props} />
<div className={cn("overflow-y-auto flex flex-col gap-4 w-[calc(100%+3rem)] -mx-6 px-6 my-2 py-2", className)} {...props} />
);
const DialogHeader = ({

View File

@ -11,7 +11,7 @@ import {
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Label, SpanLabel } from "@/components/ui/label";
const Form = FormProvider;
@ -91,7 +91,7 @@ const FormLabel = React.forwardRef<
const { error } = useFormField();
return (
<Label
<SpanLabel
ref={ref}
className={cn(error && "text-destructive", className)}
{...props}

View File

@ -15,7 +15,7 @@ const Label = React.forwardRef<
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<span
<label
ref={ref}
className={cn(labelVariants(), className)}
{...props}
@ -23,4 +23,17 @@ const Label = React.forwardRef<
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
const SpanLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<span
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
SpanLabel.displayName = LabelPrimitive.Root.displayName;
export { Label, SpanLabel };