"use client" import { useEffect, useMemo, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import type { Resolver } from "react-hook-form" import { useForm } from "react-hook-form" import { z } from "zod/v4" import { ProjectDialog } from "@/components/project-dialog" import { Button } from "@/components/ui/button" import { buildDashboardCrudFormValues, normalizeDashboardCrudSubmitValues, type DashboardCrudFormField, type DashboardCrudFormOption, } from "./dashboard-crud-utils" import { DashboardCrudFieldControl } from "./dashboard-crud-field-control" type DashboardCrudFormDialogProps = { open: boolean saving: boolean item: TItem | null itemId: number | null fields: DashboardCrudFormField[] fetchDetail?: (id: number) => Promise transformSubmitValues?: ( values: Record, context: { mode: "create" | "edit"; item: TItem | null } ) => TPayload labels: { createTitle: string editTitle: string create: string save: string saving: string cancel: string loadingDetail: string required: string invalidNumber: string minValue: (min: number) => string maxValue: (max: number) => string } onOpenChange: (open: boolean) => void onSubmit: (payload: TPayload) => Promise } function createFormSchema( fields: ReadonlyArray>, labels: DashboardCrudFormDialogProps["labels"] ) { const shape: Record> = {} fields.forEach((field) => { let schema = field.trim ? z.string().trim() : z.string() if (field.required) { schema = schema.min(1, field.requiredMessage ?? labels.required) } if (field.pattern) { schema = schema.regex(field.pattern, field.patternMessage ?? labels.required) } if (field.type === "number") { schema = schema.refine((value) => { if (!value.trim()) return !field.required return Number.isFinite(Number(value)) }, labels.invalidNumber) if (field.min !== undefined) { schema = schema.refine((value) => !value.trim() || Number(value) >= field.min!, { message: labels.minValue(field.min), }) } if (field.max !== undefined) { schema = schema.refine((value) => !value.trim() || Number(value) <= field.max!, { message: labels.maxValue(field.max), }) } } shape[field.name] = schema }) return z.object(shape) } function normalizeFormLayoutFields(fields: DashboardCrudFormField[]) { return fields.map((field) => field.type === "textarea" ? { ...field, colSpan: field.colSpan ?? 2 } : field ) } export function DashboardCrudFormDialog({ open, saving, item, itemId, fields, fetchDetail, transformSubmitValues, labels, onOpenChange, onSubmit, }: DashboardCrudFormDialogProps) { const layoutFields = useMemo(() => normalizeFormLayoutFields(fields), [fields]) const initialValues = useMemo( () => buildDashboardCrudFormValues(fields, item), [fields, item] ) const schema = useMemo(() => createFormSchema(fields, labels), [fields, labels]) const resolver = useMemo( () => zodResolver(schema as never) as Resolver< Record, undefined, Record >, [schema] ) const [fetchedDetail, setFetchedDetail] = useState<{ id: number item: TItem } | null>(null) const [fieldOptions, setFieldOptions] = useState< Record> >({}) const form = useForm, undefined, Record>({ resolver, defaultValues: initialValues, }) const { control, handleSubmit, register, reset, formState: { errors }, } = form const formId = "dashboard-crud-edit-form" const mode = itemId ? "edit" : "create" const loadingDetail = Boolean( itemId && fetchDetail && fetchedDetail?.id !== itemId ) const detailItem = itemId && fetchDetail && fetchedDetail?.id === itemId ? fetchedDetail.item : item useEffect(() => { let cancelled = false if (!itemId || !fetchDetail) { reset(initialValues) return } void fetchDetail(itemId) .then((detail) => { if (cancelled) return setFetchedDetail({ id: itemId, item: detail }) reset(buildDashboardCrudFormValues(fields, detail)) }) return () => { cancelled = true } }, [fetchDetail, fields, initialValues, item, itemId, reset]) useEffect(() => { if (!open) return let cancelled = false const loaders = fields .filter((field) => field.type === "select" && field.loadOptions) .map(async (field) => { const options = await field.loadOptions!() return [field.name, options] as const }) if (loaders.length === 0) return void Promise.all(loaders).then((entries) => { if (cancelled) return setFieldOptions((current) => ({ ...current, ...Object.fromEntries(entries), })) }) return () => { cancelled = true } }, [fields, open]) async function submit(values: Record) { const normalizedValues = normalizeDashboardCrudSubmitValues(fields, values) const payload = transformSubmitValues ? transformSubmitValues(normalizedValues, { mode, item: detailItem }) : (normalizedValues as TPayload) await onSubmit(payload) } if (!open || fields.length === 0) { return null } return ( } > {loadingDetail ? (
{labels.loadingDetail}
) : (
{layoutFields.map((field) => ( ))} )}
) }