mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
SmartForm
This commit is contained in:
parent
92a4a18632
commit
c4a16ad2a9
@ -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({
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
100
packages/stack-server/src/components/smart-form.tsx
Normal file
100
packages/stack-server/src/components/smart-form.tsx
Normal 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.`);
|
||||
}
|
||||
@ -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 = ({
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user