shadcn-ui / ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.
https://ui.shadcn.com
MIT License
74.17k stars 4.58k forks source link

Shadcn UI + React hook form + Zod + Next Js, dynamic form built with Directus #2795

Closed DevDiegoVillalobos closed 5 months ago

DevDiegoVillalobos commented 8 months ago

Hello everyone, I am trying to implement dynamic forms with directus, it happens that this gives me the guidelines to define the form, ie, what kind of interface expected, value, validations, etc., it would be a question only to generate the schema validations, and interfaces in next js, however I get a little lost with the fact of how the fields are recorded and how to apply the validations, does anyone have any practical example of this or similar?

I leave my code as an example and as a note, the fact that my problem goes more with the custom interfaces, that is to say with the interfaces provided by Shadcn UI goes great.

"use client" import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ControllerRenderProps, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { DirectusField, createItem, readItem, updateItem } from '@directus/sdk'; import { Schema, z } from 'zod'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Button } from '@/components/ui/button'; import { Input } from './_type_fields/_input/input'; import { CalendarComponent } from './_type_fields/_datetime/calendar'; import { SelectDropdownComponent } from './_type_fields/_select_dropdown/select'; import { transformToTitleCase } from '@/lib/utils'; import directus, { handleError } from '@/lib/directus'; import { ApiCollections, components } from '../../../../../types/api-collection'; import { toast } from '@/components/ui/use-toast'; import { useRouter } from 'next/navigation'; import { SelectDropdownM2OComponent } from './_type_fields/_select_dropdown_m2o/select-dropdown-m2o'; import { TextareaComponent } from './_type_fields/_textarea/textarea'; import { FilesComponent } from './_type_fields/_files/filesComponent';

const getSchemaForFieldType = (field: DirectusField): z.ZodType<any, any> | null => { switch (field.type) { case 'string': case 'text': case 'hash': const stringSchema = z.coerce.string(); if (field.schema?.max_length) { stringSchema.max(field.schema.max_length); } return stringSchema; case 'boolean': return z.boolean(); case 'integer': case 'bigInteger': const numericSchema = z.coerce.number({ required_error: "Required", invalid_type_error: "Must be a number", }).int({ message: "Must be an integer" }); if (field.schema?.numeric_precision) { numericSchema.min(field.schema.numeric_precision); } if (field.schema?.numeric_scale) { numericSchema.max(field.schema.numeric_scale); } return numericSchema; case 'float': case 'decimal': const decimalSchema = z.coerce.number({ required_error: "Required", invalid_type_error: "Must be a number", }); if (field.schema?.numeric_precision) { decimalSchema.min(field.schema.numeric_precision); } if (field.schema?.numeric_scale) { decimalSchema.max(field.schema.numeric_scale); } return decimalSchema; case 'timestamp': case 'dateTime': case 'date': case 'time': const dateSchema = z.coerce.date(); if (field.schema?.is_nullable) { dateSchema.nullable(); } return dateSchema; case 'json': return z.object({ / Schema JSON / }); case 'csv': return z.array(z.string()); case 'uuid': const uuidSchema = z.string().uuid(); return uuidSchema; case 'binary': return z.any(); case 'alias': return null; default: return null; } };

const getDefaultForFieldType = (field: DirectusField): any => { return field.schema?.default_value != undefined ? field.schema?.default_value : ''; };

async function getItem(collectionName: string, id: string) { try { let data = await directus.request(readItem(collectionName as keyof ApiCollections, id)) as unknown as ApiCollections[keyof ApiCollections][]; return data; } catch (error: any) { console.log(error); handleError(error); return [] } }

const createFormSchemaAndDefaults = (fields: DirectusField[], item: { [key: string]: any } | null) => { const schemaObject: { [key: string]: z.ZodType<any, any> } = {}; const defaultValues: { [key: string]: any } = {};

fields.forEach(field => {
    const isRequired = field.meta?.required && field.schema?.is_nullable;
    const fieldSchema = getSchemaForFieldType(field);

    if (fieldSchema) {
        schemaObject[field.field] = isRequired ? fieldSchema : fieldSchema.optional();
        if (item && item.hasOwnProperty(field.field)) {
            defaultValues[field.field] = item[field.field];
        } else {
            defaultValues[field.field] = field.schema?.default_value != undefined ? field.schema?.default_value : getDefaultForFieldType(field);

        }
    }
});

const formSchema = z.object(schemaObject);
return { formSchema, defaultValues };

};

const saveItem = async (data: { [key: string]: any }, formId: string) => { try { await directus.request( createItem(formId as keyof ApiCollections, data) ); toast({ title: "Success", description: "Item created" }); return true; } catch (error: any) { handleError(error); return false; } };

const updateAnItem = async (data: { [key: string]: any }, formId: string, idItem: string | number) => { try { await directus.request( updateItem(formId as keyof ApiCollections, idItem, data) ); toast({ title: "Success", description: "Item updated" }); return true; } catch (error: any) { handleError(error); return false; } };

export default function DynamicFormContainer({ fields, formId, action }: { fields: DirectusField[], formId: string, action: string }) { if (!fields || fields.length === 0) { return

Loading...
; }

return <DynamicForm fields={fields} formId={formId} action={action} />;

}

function DynamicForm({ fields, formId, action }: { fields: DirectusField[], formId: string, action: string }) { const [loading, setLoading] = useState(true); const [formData, setFormData] = useState<{ formSchema: z.ZodTypeAny; defaultValues: { [key: string]: any } }>(); const router = useRouter();

useEffect(() => {
    const fetchData = async () => {
        setLoading(true);
        let item = null;
        if (action !== "%2B") {
            item = await getItem(formId, action);
        }
        const data = createFormSchemaAndDefaults(fields, item);
        console.log(
            "formData.formSchema",
            data.formSchema
        );

        setFormData(data);
        setLoading(false);
    };

    fetchData();
}, [fields, formId, action]);

const form = useForm({
    resolver: formData ? zodResolver(formData.formSchema) : undefined,
    defaultValues: formData ? formData.defaultValues : {},
    mode: "onBlur",
});

const onSubmit = useCallback(async (data: { [key: string]: any }) => {
    console.log("data", data);
    let res;
    if (action != "%2B") {
        res = await updateAnItem(data, formId, action);
    } else {
        res = await saveItem(data, formId);
    }
    console.log("res", res);

    if (res) {
        router.push(`/report/${formId}`);
    }
}, [formId, action, router]);

if (loading || !formData) {
    return <div>Loading...</div>;
}

return (
    <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
            {fields.map((fieldData) => (
                <FormField
                    control={form.control}
                    key={fieldData.field}
                    name={fieldData.field}
                    defaultValue={formData.defaultValues[fieldData.field]}
                    render={({ field }) => (
                        <FormItem>
                            <FormLabel>{transformToTitleCase(field.name)}</FormLabel>
                            <FormControl>
                                {renderInputComponent(fieldData, field)}
                            </FormControl>
                            <FormMessage></FormMessage>
                        </FormItem>
                    )}
                />
            ))}
            <Button type="submit">Submit</Button>
        </form>
    </Form>
);

}

const renderInputComponent = (fieldData: DirectusField<Schema<any, z.ZodTypeDef, any>>, field: ControllerRenderProps<{ [key: string]: any }, string>) => { switch (fieldData.meta.interface) { case 'input': return fieldData.type === 'integer' || fieldData.type === 'bigInteger' || fieldData.type === 'float' || fieldData.type === 'decimal' ? ( <Input {...field} {...fieldData} type='number' /> ) : ( <Input {...field} {...fieldData} /> ); case 'datetime': return <CalendarComponent {...field} field={fieldData} /> case 'select-dropdown': return <SelectDropdownComponent {...field} {...fieldData} /> case 'select-dropdown-m2o': return <SelectDropdownM2OComponent {...field} field={fieldData} /> case 'input-rich-text-md': return <TextareaComponent {...field} /> case 'files': return default: return null; } };

shadcn commented 5 months ago

This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.