From c4a16ad2a95afb0b0440d8267106cbb9e1ff4f08 Mon Sep 17 00:00:00 2001 From: Stan Wohlwend Date: Sat, 18 May 2024 10:51:54 +0200 Subject: [PATCH] SmartForm --- .../[projectId]/api-keys/page-client.tsx | 19 ++-- .../src/components/action-dialog.tsx | 11 +- .../src/components/form-dialog.tsx | 48 ++++++++- .../src/components/form-fields.tsx | 15 ++- .../src/components/smart-form.tsx | 100 ++++++++++++++++++ .../stack-server/src/components/ui/dialog.tsx | 2 +- .../stack-server/src/components/ui/form.tsx | 4 +- .../stack-server/src/components/ui/label.tsx | 17 ++- 8 files changed, 189 insertions(+), 27 deletions(-) create mode 100644 packages/stack-server/src/components/smart-form.tsx diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/api-keys/page-client.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/api-keys/page-client.tsx index 046675583..d5b7c0901 100644 --- a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/api-keys/page-client.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/api-keys/page-client.tsx @@ -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) => ( + ({ value, label }))} /> + ) + }), }); function CreateDialog(props: { @@ -68,19 +72,12 @@ function CreateDialog(props: { }) { const stackAdminApp = useAdminApp(); - return ( - <> - - ({ value, label }))} /> - - )} onSubmit={async (values) => { const expiresIn = parseInt(values.expiresIn); const newKey = await stackAdminApp.createApiKeySet({ diff --git a/packages/stack-server/src/components/action-dialog.tsx b/packages/stack-server/src/components/action-dialog.tsx index 76ef378f8..1a8bd886e 100644 --- a/packages/stack-server/src/components/action-dialog.tsx +++ b/packages/stack-server/src/components/action-dialog.tsx @@ -12,7 +12,7 @@ import { Label } from "./ui/label"; export type ActionDialogProps = { trigger?: React.ReactNode, open?: boolean, - onClose?: () => void | Promise, + 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 ( - + {props.trigger && {props.trigger} } diff --git a/packages/stack-server/src/components/form-dialog.tsx b/packages/stack-server/src/components/form-dialog.tsx index 6d18c399a..40cecce21 100644 --- a/packages/stack-server/src/components/form-dialog.tsx +++ b/packages/stack-server/src/components/form-dialog.tsx @@ -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>( + props: Omit & { + formSchema: S, + onSubmit: (values: yup.InferType) => Promise | void | 'prevent-close', + }, +) { + const formId = `${useId()}-form`; + const [submitting, setSubmitting] = useState(false); + const [openState, setOpenState] = useState(false); + const handleSubmit = async (values: yup.InferType) => { + const res = await props.onSubmit(values); + if (res !== 'prevent-close') { + setOpenState(false); + props.onOpenChange?.(false); + props.onClose?.(); + } + }; + + return ( + { 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) + }, + }} + > + + + ); +} export function FormDialog( props: Omit & { @@ -33,8 +73,8 @@ export function FormDialog( 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( {...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( ); -} \ No newline at end of file +} diff --git a/packages/stack-server/src/components/form-fields.tsx b/packages/stack-server/src/components/form-fields.tsx index ce17ec3d6..526c0571f 100644 --- a/packages/stack-server/src/components/form-fields.tsx +++ b/packages/stack-server/src/components/form-fields.tsx @@ -23,6 +23,7 @@ export function InputField(props: { label: string, placeholder?: string, required?: boolean, + disabled?: boolean, }) { return ( (props: { @@ -49,6 +50,7 @@ export function SwitchField(props: { label: string, required?: boolean, noCard?: boolean, + disabled?: boolean, }) { return ( (props: { @@ -80,6 +83,7 @@ export function SmallSwitchField(props: { name: Path, label: string, required?: boolean, + disabled?: boolean, }) { return ( (props: { @@ -110,6 +115,7 @@ export function SwitchListField(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(props: { field.onChange(field.value.filter((v: any) => v !== provider.value)); } }} + disabled={props.disabled} /> @@ -151,6 +158,7 @@ export function DateField(props: { name: Path, label: string, required?: boolean, + disabled?: boolean, }) { return ( (props: { "w-[240px] pl-3 text-left font-normal", !field.value && "text-muted-foreground" )} + disabled={props.disabled} > {field.value ? field.value.toLocaleDateString() : Pick a date} @@ -180,6 +189,7 @@ export function DateField(props: { selected={field.value} onSelect={field.onChange} initialFocus + disabled={props.disabled} /> @@ -197,6 +207,7 @@ export function SelectField(props: { options: { value: string, label: string }[], placeholder?: string, required?: boolean, + disabled?: boolean, }) { return ( (props: { {props.label} - diff --git a/packages/stack-server/src/components/smart-form.tsx b/packages/stack-server/src/components/smart-form.tsx new file mode 100644 index 000000000..0f122433d --- /dev/null +++ b/packages/stack-server/src/components/smart-form.tsx @@ -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['control'], name: string, label: string, disabled: boolean }) => React.ReactNode, + } +} + +export function SmartForm>(props: { + formSchema: S, + onSubmit: (values: yup.InferType) => Promise, + 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, 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 ( +
+ runAsynchronously(handleSubmit(e))} id={props.formId} className="space-y-4"> + {Object.entries(details.fields).map(([fieldId, field]) => ( + + ))} + + + ); +}; +SmartForm.displayName = 'SmartForm'; + +function SmartFormField(props: { + id: string, + description: yup.SchemaFieldDescription, + form: ReturnType, + 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 ; + } + case 'date': { + return ; + } + } + + 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.`); +} diff --git a/packages/stack-server/src/components/ui/dialog.tsx b/packages/stack-server/src/components/ui/dialog.tsx index d658c80f4..1b8594337 100644 --- a/packages/stack-server/src/components/ui/dialog.tsx +++ b/packages/stack-server/src/components/ui/dialog.tsx @@ -57,7 +57,7 @@ const DialogBody = ({ className, ...props }: React.HTMLAttributes) => ( -
+
); const DialogHeader = ({ diff --git a/packages/stack-server/src/components/ui/form.tsx b/packages/stack-server/src/components/ui/form.tsx index 272e0bfc9..6ab1e72bd 100644 --- a/packages/stack-server/src/components/ui/form.tsx +++ b/packages/stack-server/src/components/ui/form.tsx @@ -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 ( -