diff --git a/.changeset/brave-bushes-sort.md b/.changeset/brave-bushes-sort.md new file mode 100644 index 0000000..9c1f81c --- /dev/null +++ b/.changeset/brave-bushes-sort.md @@ -0,0 +1,14 @@ +--- +"@buildnbuzz/buzzform": patch +--- + +**Features** +- Added **Output Configuration** support to transform form data into flat path-delimited keys (e.g., `person.address.street`). +- Introduced `output` configuration to `FormProvider` and `useForm` for global or per-form control of submission data shape. + +**Internal Changes** +- Implemented `transformFormOutput` utility for flattening nested form objects into delimited path keys. +- Updated `useForm` to automatically apply output transformation to `getValues()`, `watch()`, and `onSubmit` data. + +**Types** +- Exported new `OutputConfig`, `OutputType`, and `PathDelimiter` types. diff --git a/apps/web/.source/browser.ts b/apps/web/.source/browser.ts index b4b97c4..377910c 100644 --- a/apps/web/.source/browser.ts +++ b/apps/web/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"conditional-logic.mdx": () => import("../content/docs/conditional-logic.mdx?collection=docs"), "configuration.mdx": () => import("../content/docs/configuration.mdx?collection=docs"), "custom-rendering.mdx": () => import("../content/docs/custom-rendering.mdx?collection=docs"), "form-component.mdx": () => import("../content/docs/form-component.mdx?collection=docs"), "index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "quick-start.mdx": () => import("../content/docs/quick-start.mdx?collection=docs"), "schema.mdx": () => import("../content/docs/schema.mdx?collection=docs"), "validation.mdx": () => import("../content/docs/validation.mdx?collection=docs"), "your-first-form.mdx": () => import("../content/docs/your-first-form.mdx?collection=docs"), "fields/types.mdx": () => import("../content/docs/fields/types.mdx?collection=docs"), "fields/layout/array.mdx": () => import("../content/docs/fields/layout/array.mdx?collection=docs"), "fields/layout/collapsible.mdx": () => import("../content/docs/fields/layout/collapsible.mdx?collection=docs"), "fields/layout/group.mdx": () => import("../content/docs/fields/layout/group.mdx?collection=docs"), "fields/layout/row.mdx": () => import("../content/docs/fields/layout/row.mdx?collection=docs"), "fields/layout/tabs.mdx": () => import("../content/docs/fields/layout/tabs.mdx?collection=docs"), "fields/data/checkbox-group.mdx": () => import("../content/docs/fields/data/checkbox-group.mdx?collection=docs"), "fields/data/checkbox.mdx": () => import("../content/docs/fields/data/checkbox.mdx?collection=docs"), "fields/data/date.mdx": () => import("../content/docs/fields/data/date.mdx?collection=docs"), "fields/data/number.mdx": () => import("../content/docs/fields/data/number.mdx?collection=docs"), "fields/data/password.mdx": () => import("../content/docs/fields/data/password.mdx?collection=docs"), "fields/data/radio.mdx": () => import("../content/docs/fields/data/radio.mdx?collection=docs"), "fields/data/select.mdx": () => import("../content/docs/fields/data/select.mdx?collection=docs"), "fields/data/switch.mdx": () => import("../content/docs/fields/data/switch.mdx?collection=docs"), "fields/data/tags.mdx": () => import("../content/docs/fields/data/tags.mdx?collection=docs"), "fields/data/text.mdx": () => import("../content/docs/fields/data/text.mdx?collection=docs"), "fields/data/textarea.mdx": () => import("../content/docs/fields/data/textarea.mdx?collection=docs"), "fields/data/upload.mdx": () => import("../content/docs/fields/data/upload.mdx?collection=docs"), }), + docs: create.doc("docs", {"conditional-logic.mdx": () => import("../content/docs/conditional-logic.mdx?collection=docs"), "configuration.mdx": () => import("../content/docs/configuration.mdx?collection=docs"), "custom-rendering.mdx": () => import("../content/docs/custom-rendering.mdx?collection=docs"), "form-component.mdx": () => import("../content/docs/form-component.mdx?collection=docs"), "index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "output-configuration.mdx": () => import("../content/docs/output-configuration.mdx?collection=docs"), "quick-start.mdx": () => import("../content/docs/quick-start.mdx?collection=docs"), "schema.mdx": () => import("../content/docs/schema.mdx?collection=docs"), "validation.mdx": () => import("../content/docs/validation.mdx?collection=docs"), "your-first-form.mdx": () => import("../content/docs/your-first-form.mdx?collection=docs"), "fields/types.mdx": () => import("../content/docs/fields/types.mdx?collection=docs"), "fields/data/checkbox-group.mdx": () => import("../content/docs/fields/data/checkbox-group.mdx?collection=docs"), "fields/data/checkbox.mdx": () => import("../content/docs/fields/data/checkbox.mdx?collection=docs"), "fields/data/date.mdx": () => import("../content/docs/fields/data/date.mdx?collection=docs"), "fields/data/number.mdx": () => import("../content/docs/fields/data/number.mdx?collection=docs"), "fields/data/password.mdx": () => import("../content/docs/fields/data/password.mdx?collection=docs"), "fields/data/radio.mdx": () => import("../content/docs/fields/data/radio.mdx?collection=docs"), "fields/data/select.mdx": () => import("../content/docs/fields/data/select.mdx?collection=docs"), "fields/data/switch.mdx": () => import("../content/docs/fields/data/switch.mdx?collection=docs"), "fields/data/tags.mdx": () => import("../content/docs/fields/data/tags.mdx?collection=docs"), "fields/data/text.mdx": () => import("../content/docs/fields/data/text.mdx?collection=docs"), "fields/data/textarea.mdx": () => import("../content/docs/fields/data/textarea.mdx?collection=docs"), "fields/data/upload.mdx": () => import("../content/docs/fields/data/upload.mdx?collection=docs"), "fields/layout/array.mdx": () => import("../content/docs/fields/layout/array.mdx?collection=docs"), "fields/layout/collapsible.mdx": () => import("../content/docs/fields/layout/collapsible.mdx?collection=docs"), "fields/layout/group.mdx": () => import("../content/docs/fields/layout/group.mdx?collection=docs"), "fields/layout/row.mdx": () => import("../content/docs/fields/layout/row.mdx?collection=docs"), "fields/layout/tabs.mdx": () => import("../content/docs/fields/layout/tabs.mdx?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/apps/web/.source/server.ts b/apps/web/.source/server.ts index 2fd4dd8..a6558bc 100644 --- a/apps/web/.source/server.ts +++ b/apps/web/.source/server.ts @@ -1,26 +1,27 @@ // @ts-nocheck -import * as __fd_glob_31 from "../content/docs/fields/data/upload.mdx?collection=docs" -import * as __fd_glob_30 from "../content/docs/fields/data/textarea.mdx?collection=docs" -import * as __fd_glob_29 from "../content/docs/fields/data/text.mdx?collection=docs" -import * as __fd_glob_28 from "../content/docs/fields/data/tags.mdx?collection=docs" -import * as __fd_glob_27 from "../content/docs/fields/data/switch.mdx?collection=docs" -import * as __fd_glob_26 from "../content/docs/fields/data/select.mdx?collection=docs" -import * as __fd_glob_25 from "../content/docs/fields/data/radio.mdx?collection=docs" -import * as __fd_glob_24 from "../content/docs/fields/data/password.mdx?collection=docs" -import * as __fd_glob_23 from "../content/docs/fields/data/number.mdx?collection=docs" -import * as __fd_glob_22 from "../content/docs/fields/data/date.mdx?collection=docs" -import * as __fd_glob_21 from "../content/docs/fields/data/checkbox.mdx?collection=docs" -import * as __fd_glob_20 from "../content/docs/fields/data/checkbox-group.mdx?collection=docs" -import * as __fd_glob_19 from "../content/docs/fields/layout/tabs.mdx?collection=docs" -import * as __fd_glob_18 from "../content/docs/fields/layout/row.mdx?collection=docs" -import * as __fd_glob_17 from "../content/docs/fields/layout/group.mdx?collection=docs" -import * as __fd_glob_16 from "../content/docs/fields/layout/collapsible.mdx?collection=docs" -import * as __fd_glob_15 from "../content/docs/fields/layout/array.mdx?collection=docs" -import * as __fd_glob_14 from "../content/docs/fields/types.mdx?collection=docs" -import * as __fd_glob_13 from "../content/docs/your-first-form.mdx?collection=docs" -import * as __fd_glob_12 from "../content/docs/validation.mdx?collection=docs" -import * as __fd_glob_11 from "../content/docs/schema.mdx?collection=docs" -import * as __fd_glob_10 from "../content/docs/quick-start.mdx?collection=docs" +import * as __fd_glob_32 from "../content/docs/fields/layout/tabs.mdx?collection=docs" +import * as __fd_glob_31 from "../content/docs/fields/layout/row.mdx?collection=docs" +import * as __fd_glob_30 from "../content/docs/fields/layout/group.mdx?collection=docs" +import * as __fd_glob_29 from "../content/docs/fields/layout/collapsible.mdx?collection=docs" +import * as __fd_glob_28 from "../content/docs/fields/layout/array.mdx?collection=docs" +import * as __fd_glob_27 from "../content/docs/fields/data/upload.mdx?collection=docs" +import * as __fd_glob_26 from "../content/docs/fields/data/textarea.mdx?collection=docs" +import * as __fd_glob_25 from "../content/docs/fields/data/text.mdx?collection=docs" +import * as __fd_glob_24 from "../content/docs/fields/data/tags.mdx?collection=docs" +import * as __fd_glob_23 from "../content/docs/fields/data/switch.mdx?collection=docs" +import * as __fd_glob_22 from "../content/docs/fields/data/select.mdx?collection=docs" +import * as __fd_glob_21 from "../content/docs/fields/data/radio.mdx?collection=docs" +import * as __fd_glob_20 from "../content/docs/fields/data/password.mdx?collection=docs" +import * as __fd_glob_19 from "../content/docs/fields/data/number.mdx?collection=docs" +import * as __fd_glob_18 from "../content/docs/fields/data/date.mdx?collection=docs" +import * as __fd_glob_17 from "../content/docs/fields/data/checkbox.mdx?collection=docs" +import * as __fd_glob_16 from "../content/docs/fields/data/checkbox-group.mdx?collection=docs" +import * as __fd_glob_15 from "../content/docs/fields/types.mdx?collection=docs" +import * as __fd_glob_14 from "../content/docs/your-first-form.mdx?collection=docs" +import * as __fd_glob_13 from "../content/docs/validation.mdx?collection=docs" +import * as __fd_glob_12 from "../content/docs/schema.mdx?collection=docs" +import * as __fd_glob_11 from "../content/docs/quick-start.mdx?collection=docs" +import * as __fd_glob_10 from "../content/docs/output-configuration.mdx?collection=docs" import * as __fd_glob_9 from "../content/docs/installation.mdx?collection=docs" import * as __fd_glob_8 from "../content/docs/index.mdx?collection=docs" import * as __fd_glob_7 from "../content/docs/form-component.mdx?collection=docs" @@ -39,4 +40,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "fields/meta.json": __fd_glob_1, "fields/data/meta.json": __fd_glob_2, "fields/layout/meta.json": __fd_glob_3, }, {"conditional-logic.mdx": __fd_glob_4, "configuration.mdx": __fd_glob_5, "custom-rendering.mdx": __fd_glob_6, "form-component.mdx": __fd_glob_7, "index.mdx": __fd_glob_8, "installation.mdx": __fd_glob_9, "quick-start.mdx": __fd_glob_10, "schema.mdx": __fd_glob_11, "validation.mdx": __fd_glob_12, "your-first-form.mdx": __fd_glob_13, "fields/types.mdx": __fd_glob_14, "fields/layout/array.mdx": __fd_glob_15, "fields/layout/collapsible.mdx": __fd_glob_16, "fields/layout/group.mdx": __fd_glob_17, "fields/layout/row.mdx": __fd_glob_18, "fields/layout/tabs.mdx": __fd_glob_19, "fields/data/checkbox-group.mdx": __fd_glob_20, "fields/data/checkbox.mdx": __fd_glob_21, "fields/data/date.mdx": __fd_glob_22, "fields/data/number.mdx": __fd_glob_23, "fields/data/password.mdx": __fd_glob_24, "fields/data/radio.mdx": __fd_glob_25, "fields/data/select.mdx": __fd_glob_26, "fields/data/switch.mdx": __fd_glob_27, "fields/data/tags.mdx": __fd_glob_28, "fields/data/text.mdx": __fd_glob_29, "fields/data/textarea.mdx": __fd_glob_30, "fields/data/upload.mdx": __fd_glob_31, }); \ No newline at end of file +export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "fields/meta.json": __fd_glob_1, "fields/data/meta.json": __fd_glob_2, "fields/layout/meta.json": __fd_glob_3, }, {"conditional-logic.mdx": __fd_glob_4, "configuration.mdx": __fd_glob_5, "custom-rendering.mdx": __fd_glob_6, "form-component.mdx": __fd_glob_7, "index.mdx": __fd_glob_8, "installation.mdx": __fd_glob_9, "output-configuration.mdx": __fd_glob_10, "quick-start.mdx": __fd_glob_11, "schema.mdx": __fd_glob_12, "validation.mdx": __fd_glob_13, "your-first-form.mdx": __fd_glob_14, "fields/types.mdx": __fd_glob_15, "fields/data/checkbox-group.mdx": __fd_glob_16, "fields/data/checkbox.mdx": __fd_glob_17, "fields/data/date.mdx": __fd_glob_18, "fields/data/number.mdx": __fd_glob_19, "fields/data/password.mdx": __fd_glob_20, "fields/data/radio.mdx": __fd_glob_21, "fields/data/select.mdx": __fd_glob_22, "fields/data/switch.mdx": __fd_glob_23, "fields/data/tags.mdx": __fd_glob_24, "fields/data/text.mdx": __fd_glob_25, "fields/data/textarea.mdx": __fd_glob_26, "fields/data/upload.mdx": __fd_glob_27, "fields/layout/array.mdx": __fd_glob_28, "fields/layout/collapsible.mdx": __fd_glob_29, "fields/layout/group.mdx": __fd_glob_30, "fields/layout/row.mdx": __fd_glob_31, "fields/layout/tabs.mdx": __fd_glob_32, }); \ No newline at end of file diff --git a/apps/web/app/(builder)/components/builder-form-context.tsx b/apps/web/app/(builder)/components/builder-form-context.tsx index c5af926..ec92e36 100644 --- a/apps/web/app/(builder)/components/builder-form-context.tsx +++ b/apps/web/app/(builder)/components/builder-form-context.tsx @@ -49,6 +49,7 @@ export function BuilderFormProvider({ }: BuilderFormProviderProps) { const nodes = useBuilderStore((s) => s.nodes); const rootIds = useBuilderStore((s) => s.rootIds); + const outputConfig = useBuilderStore((s) => s.outputConfig); // Memoize derivation pipeline to prevent recomputation on unrelated store updates const fields = React.useMemo( @@ -76,6 +77,7 @@ export function BuilderFormProvider({ fields={fields} defaultValues={defaultValues} onSubmit={onSubmit ?? (() => {})} + output={outputConfig} mode="onBlur" showSubmit={false} > diff --git a/apps/web/app/(builder)/components/canvas.tsx b/apps/web/app/(builder)/components/canvas.tsx index b3e1658..5037165 100644 --- a/apps/web/app/(builder)/components/canvas.tsx +++ b/apps/web/app/(builder)/components/canvas.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; import { useDroppable } from "@dnd-kit/core"; import { useBuilderStore } from "../lib/store"; import { useBuilderKeyboardShortcuts } from "../lib/use-keyboard-shortcuts"; @@ -17,20 +17,21 @@ import { EmptyTitle, } from "@/components/ui/empty"; import { HugeiconsIcon } from "@hugeicons/react"; -import { DragDropIcon } from "@hugeicons/core-free-icons"; +import { + DragDropIcon, + Copy01Icon, + Tick01Icon, +} from "@hugeicons/core-free-icons"; import { PreviewForm } from "./preview/preview-form"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; export function Canvas() { useBuilderKeyboardShortcuts(); const onSubmit = async (data: Record) => { await new Promise((r) => setTimeout(r, 500)); toast("Form Submitted!", { - description: ( -
-          {JSON.stringify(data, null, 2)}
-        
- ), + description: , duration: 10000, }); }; @@ -135,3 +136,34 @@ const EmptyCanvas = () => { ); }; + +function SubmitToastContent({ data }: { data: Record }) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(JSON.stringify(data, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ +
+        {JSON.stringify(data, null, 2)}
+      
+
+ ); +} diff --git a/apps/web/app/(builder)/components/export-sheet.tsx b/apps/web/app/(builder)/components/export-sheet.tsx index f357dee..a2dc84b 100644 --- a/apps/web/app/(builder)/components/export-sheet.tsx +++ b/apps/web/app/(builder)/components/export-sheet.tsx @@ -41,6 +41,7 @@ export function ExportSheet() { const rootIds = useBuilderStore((state) => state.rootIds); const formId = useBuilderStore((state) => state.formId); const formName = useBuilderStore((state) => state.formName); + const outputConfig = useBuilderStore((state) => state.outputConfig); React.useEffect(() => { if (!open) return; @@ -65,8 +66,8 @@ export function ExportSheet() { const componentCode = React.useMemo(() => { if (!open) return ""; - return generateComponentCode(nodes, rootIds, formName); - }, [open, nodes, rootIds, formName]); + return generateComponentCode(nodes, rootIds, formName, outputConfig); + }, [open, nodes, rootIds, formName, outputConfig]); const schemaJson = React.useMemo(() => { if (!open) return ""; diff --git a/apps/web/app/(builder)/components/properties/global-settings-form.tsx b/apps/web/app/(builder)/components/properties/global-settings-form.tsx new file mode 100644 index 0000000..8cb7bf9 --- /dev/null +++ b/apps/web/app/(builder)/components/properties/global-settings-form.tsx @@ -0,0 +1,98 @@ +"use client"; + +import * as React from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { + createSchema, + useForm, + type FormAdapter, + type OutputConfig, + type PathDelimiter, +} from "@buildnbuzz/buzzform"; +import { useBuilderStore } from "../../lib/store"; +import { formSettingsProperties } from "../../lib/properties/form"; +import { RenderFields } from "@/components/buzzform/fields/render"; +import { FieldGroup } from "@/components/ui/field"; +import { unflattenFormValues } from "../../lib/properties"; + +export function GlobalSettingsForm() { + const outputConfig = useBuilderStore((s) => s.outputConfig); + const updateFormSettings = useBuilderStore((s) => s.updateFormSettings); + + const schema = useMemo(() => createSchema(formSettingsProperties), []); + + const defaultValues = useMemo(() => { + return { + outputConfig: { + type: outputConfig?.type ?? "default", + delimiter: outputConfig?.delimiter ?? ".", + }, + }; + }, [outputConfig]); + + const form = useForm({ + schema, + defaultValues, + mode: "onBlur", + output: undefined, + }); + + const currentValues = form.watch() as Record; + + // Update store when form changes + useEffect(() => { + const nested = unflattenFormValues(currentValues); + const formOutputConfig = nested.outputConfig as + | { type: string; delimiter?: string } + | undefined; + + const storeValue: OutputConfig | undefined = + formOutputConfig?.type === "path" + ? { + type: "path", + delimiter: (formOutputConfig.delimiter as PathDelimiter) ?? ".", + } + : undefined; + + // Compare with current store value + const currentStoreJson = JSON.stringify(outputConfig); + const newStoreJson = JSON.stringify(storeValue); + + if (currentStoreJson !== newStoreJson) { + updateFormSettings({ outputConfig: storeValue }); + } + }, [currentValues, outputConfig, updateFormSettings]); + + // Sync form when store changes externally (undo/redo) + const prevOutputConfigRef = useRef(outputConfig); + useEffect(() => { + const prevJson = JSON.stringify(prevOutputConfigRef.current); + const currentJson = JSON.stringify(outputConfig); + + if (prevJson !== currentJson) { + prevOutputConfigRef.current = outputConfig; + + const nextValues = { + outputConfig: { + type: outputConfig?.type ?? "default", + delimiter: outputConfig?.delimiter ?? ".", + }, + }; + + form.reset(nextValues); + } + }, [outputConfig, form]); + + return ( +
+
e.preventDefault()}> + + + +
+
+ ); +} diff --git a/apps/web/app/(builder)/components/properties/properties-panel.tsx b/apps/web/app/(builder)/components/properties/properties-panel.tsx index d964bf1..ab586dc 100644 --- a/apps/web/app/(builder)/components/properties/properties-panel.tsx +++ b/apps/web/app/(builder)/components/properties/properties-panel.tsx @@ -3,20 +3,14 @@ import { useBuilderStore } from "../../lib/store"; import { getRegistryEntry } from "../../lib/registry"; import { PropertiesForm } from "./properties-form"; +import { GlobalSettingsForm } from "./global-settings-form"; import { Sidebar, SidebarContent, SidebarHeader, useSidebar, } from "@/components/ui/sidebar"; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty"; -import { Settings04Icon } from "@hugeicons/core-free-icons"; +import { Settings01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useEffect } from "react"; @@ -60,11 +54,11 @@ export function PropertiesPanel() { ) : (
- Properties + Form Settings
)} @@ -72,19 +66,7 @@ export function PropertiesPanel() { {selectedNode && config ? ( ) : ( -
- - - - - - No Field Selected - - Select a field on the canvas to edit its properties. - - - -
+ )} diff --git a/apps/web/app/(builder)/lib/code-generator.ts b/apps/web/app/(builder)/lib/code-generator.ts index 7eba862..cecf428 100644 --- a/apps/web/app/(builder)/lib/code-generator.ts +++ b/apps/web/app/(builder)/lib/code-generator.ts @@ -1,10 +1,12 @@ import { nodesToFields } from "./schema-builder"; import type { Node } from "./types"; +import type { OutputConfig } from "@buildnbuzz/buzzform"; export function generateComponentCode( nodes: Record, rootIds: string[], - formName: string + formName: string, + outputConfig?: OutputConfig, ) { const componentName = toComponentName(formName); // 1. Get the raw field structure @@ -13,6 +15,17 @@ export function generateComponentCode( // 2. Serialize fields to JSON string, but we just stringify the whole array const schemaString = JSON.stringify(fields, null, 2); + const outputProp = outputConfig + ? (() => { + const props = [`type: "${outputConfig.type}"`]; + if (outputConfig.delimiter && outputConfig.delimiter !== ".") { + props.push(`delimiter: "${outputConfig.delimiter}"`); + } + const inner = props.join(", "); + return `\n output={{ ${inner} }}`; + })() + : ""; + // 3. Inject into template return `"use client"; @@ -29,7 +42,7 @@ export default function ${componentName}() {
{ await new Promise((r) => setTimeout(r, 1000)); toast("Form submitted!", { diff --git a/apps/web/app/(builder)/lib/persistence/document.ts b/apps/web/app/(builder)/lib/persistence/document.ts index 0f0cf9b..694663d 100644 --- a/apps/web/app/(builder)/lib/persistence/document.ts +++ b/apps/web/app/(builder)/lib/persistence/document.ts @@ -1,5 +1,6 @@ import type { ZodError } from "zod"; import type { Node } from "../types"; +import type { OutputConfig } from "@buildnbuzz/buzzform"; import { CURRENT_BUILDER_DOCUMENT_SCHEMA_VERSION, CURRENT_BUILDER_VERSION, @@ -16,6 +17,7 @@ export interface BuilderDocumentState { rootIds: string[]; formId: string; formName: string; + outputConfig?: OutputConfig; } export interface CreateBuilderDocumentOptions { @@ -42,9 +44,11 @@ export function toBuilderDocument( return { schemaVersion: CURRENT_BUILDER_DOCUMENT_SCHEMA_VERSION, - builderVersion: normalizeString(options.builderVersion) ?? CURRENT_BUILDER_VERSION, + builderVersion: + normalizeString(options.builderVersion) ?? CURRENT_BUILDER_VERSION, formId: state.formId, formName: state.formName, + outputConfig: state.outputConfig, nodes: toDocumentNodes(state.nodes), rootIds: [...state.rootIds], createdAt, @@ -60,6 +64,7 @@ export function fromBuilderDocument( return { formId: validated.formId, formName: validated.formName, + outputConfig: validated.outputConfig, nodes: toStateNodes(validated.nodes), rootIds: [...validated.rootIds], }; @@ -172,7 +177,9 @@ function toStateNodes( parentId: node.parentId, parentSlot: node.parentSlot, children: [...node.children], - ...(node.tabChildren ? { tabChildren: cloneTabChildren(node.tabChildren) } : {}), + ...(node.tabChildren + ? { tabChildren: cloneTabChildren(node.tabChildren) } + : {}), }; } @@ -210,7 +217,9 @@ function toBuilderDocumentType(parsed: ParsedBuilderDocument): BuilderDocument { parentId: node.parentId, parentSlot: node.parentSlot, children: [...node.children], - ...(node.tabChildren ? { tabChildren: cloneTabChildren(node.tabChildren) } : {}), + ...(node.tabChildren + ? { tabChildren: cloneTabChildren(node.tabChildren) } + : {}), }; } @@ -219,6 +228,7 @@ function toBuilderDocumentType(parsed: ParsedBuilderDocument): BuilderDocument { builderVersion: parsed.builderVersion, formId: parsed.formId, formName: parsed.formName, + outputConfig: parsed.outputConfig, nodes, rootIds: [...parsed.rootIds], createdAt: parsed.createdAt, diff --git a/apps/web/app/(builder)/lib/persistence/schemas.ts b/apps/web/app/(builder)/lib/persistence/schemas.ts index b4a3320..1cf26e5 100644 --- a/apps/web/app/(builder)/lib/persistence/schemas.ts +++ b/apps/web/app/(builder)/lib/persistence/schemas.ts @@ -50,13 +50,19 @@ type SerializableTabShape = { } & Record; type SerializableFieldShape = - | ({ type: SerializableNamedLeafType; name: string } & Record) + | ({ type: SerializableNamedLeafType; name: string } & Record< + string, + unknown + >) | ({ type: "group" | "array"; name: string; fields: SerializableFieldShape[]; } & Record) - | ({ type: "row"; fields: SerializableFieldShape[] } & Record) + | ({ type: "row"; fields: SerializableFieldShape[] } & Record< + string, + unknown + >) | ({ type: "collapsible"; label: string; @@ -73,65 +79,66 @@ function createNamedFieldSchema(type: TType) { .catchall(JsonValueSchema); } -export const SerializableFieldSchema: z.ZodType = z.lazy(() => - z.discriminatedUnion("type", [ - createNamedFieldSchema("text"), - createNamedFieldSchema("email"), - createNamedFieldSchema("password"), - createNamedFieldSchema("textarea"), - createNamedFieldSchema("number"), - createNamedFieldSchema("date"), - createNamedFieldSchema("datetime"), - createNamedFieldSchema("select"), - createNamedFieldSchema("checkbox-group"), - createNamedFieldSchema("checkbox"), - createNamedFieldSchema("switch"), - createNamedFieldSchema("radio"), - createNamedFieldSchema("tags"), - createNamedFieldSchema("upload"), - z - .object({ - type: z.literal("group"), - name: z.string(), - fields: z.array(SerializableFieldSchema), - }) - .catchall(JsonValueSchema), - z - .object({ - type: z.literal("array"), - name: z.string(), - fields: z.array(SerializableFieldSchema), - }) - .catchall(JsonValueSchema), - z - .object({ - type: z.literal("row"), - fields: z.array(SerializableFieldSchema), - }) - .catchall(JsonValueSchema), - z - .object({ - type: z.literal("collapsible"), - label: z.string(), - fields: z.array(SerializableFieldSchema), - }) - .catchall(JsonValueSchema), - z - .object({ - type: z.literal("tabs"), - tabs: z.array( - z - .object({ - name: z.string().optional(), - label: z.string(), - fields: z.array(SerializableFieldSchema), - }) - .catchall(JsonValueSchema), - ), - }) - .catchall(JsonValueSchema), - ]), -); +export const SerializableFieldSchema: z.ZodType = + z.lazy(() => + z.discriminatedUnion("type", [ + createNamedFieldSchema("text"), + createNamedFieldSchema("email"), + createNamedFieldSchema("password"), + createNamedFieldSchema("textarea"), + createNamedFieldSchema("number"), + createNamedFieldSchema("date"), + createNamedFieldSchema("datetime"), + createNamedFieldSchema("select"), + createNamedFieldSchema("checkbox-group"), + createNamedFieldSchema("checkbox"), + createNamedFieldSchema("switch"), + createNamedFieldSchema("radio"), + createNamedFieldSchema("tags"), + createNamedFieldSchema("upload"), + z + .object({ + type: z.literal("group"), + name: z.string(), + fields: z.array(SerializableFieldSchema), + }) + .catchall(JsonValueSchema), + z + .object({ + type: z.literal("array"), + name: z.string(), + fields: z.array(SerializableFieldSchema), + }) + .catchall(JsonValueSchema), + z + .object({ + type: z.literal("row"), + fields: z.array(SerializableFieldSchema), + }) + .catchall(JsonValueSchema), + z + .object({ + type: z.literal("collapsible"), + label: z.string(), + fields: z.array(SerializableFieldSchema), + }) + .catchall(JsonValueSchema), + z + .object({ + type: z.literal("tabs"), + tabs: z.array( + z + .object({ + name: z.string().optional(), + label: z.string(), + fields: z.array(SerializableFieldSchema), + }) + .catchall(JsonValueSchema), + ), + }) + .catchall(JsonValueSchema), + ]), + ); export const NodeDocumentSchema = z.object({ id: z.string().optional(), @@ -142,11 +149,29 @@ export const NodeDocumentSchema = z.object({ tabChildren: z.record(z.string(), z.array(z.string())).optional(), }); +const RawOutputConfigSchema = z + .object({ + type: z.enum(["default", "path"]), + delimiter: z.enum([".", "-", "_"]).optional(), + }) + .optional(); + +export const OutputConfigSchema = RawOutputConfigSchema.transform( + (val): { type: "path"; delimiter?: "." | "-" | "_" } | undefined => { + if (!val || val.type === "default") return undefined; + return { + type: val.type, + ...(val.delimiter ? { delimiter: val.delimiter } : {}), + }; + }, +); + export const BuilderDocumentSchema = z.object({ schemaVersion: z.literal(CURRENT_BUILDER_DOCUMENT_SCHEMA_VERSION), builderVersion: z.string(), formId: z.string(), formName: z.string(), + outputConfig: OutputConfigSchema, nodes: z.record(z.string(), NodeDocumentSchema), rootIds: z.array(z.string()), createdAt: z.number(), diff --git a/apps/web/app/(builder)/lib/properties.ts b/apps/web/app/(builder)/lib/properties.ts index 2b5eb7a..744c51e 100644 --- a/apps/web/app/(builder)/lib/properties.ts +++ b/apps/web/app/(builder)/lib/properties.ts @@ -297,7 +297,9 @@ export function unflattenFormValues( current = current[part] as Record; } - current[parts[parts.length - 1]] = values[key]; + const value = values[key]; + current[parts[parts.length - 1]] = + value && typeof value === "object" ? deepClone(value) : value; } return result; diff --git a/apps/web/app/(builder)/lib/properties/form.ts b/apps/web/app/(builder)/lib/properties/form.ts new file mode 100644 index 0000000..58213f8 --- /dev/null +++ b/apps/web/app/(builder)/lib/properties/form.ts @@ -0,0 +1,30 @@ +import type { Field } from "@buildnbuzz/buzzform"; + +export const formSettingsProperties: Field[] = [ + { + type: "select", + name: "outputConfig.type", + label: "Output Format", + description: "How form data is structured when submitted.", + options: [ + { label: "Nested JSON", value: "default" }, + { label: "Flat Paths", value: "path" }, + ], + defaultValue: "default", + }, + { + type: "select", + name: "outputConfig.delimiter", + label: "Path Delimiter", + description: "Separator between path segments (e.g. person.name).", + condition: (data) => + (data as { outputConfig: { type: string } }).outputConfig?.type === + "path", + options: [ + { label: ". (dot)", value: "." }, + { label: "_ (underscore)", value: "_" }, + { label: "- (dash)", value: "-" }, + ], + defaultValue: ".", + }, +]; diff --git a/apps/web/app/(builder)/lib/store.ts b/apps/web/app/(builder)/lib/store.ts index ceb62ea..a8b6c87 100644 --- a/apps/web/app/(builder)/lib/store.ts +++ b/apps/web/app/(builder)/lib/store.ts @@ -16,7 +16,7 @@ import { getNodeChildren, getTabSlotKeys, } from "./node-children"; -import type { TabsField } from "@buildnbuzz/buzzform"; +import type { TabsField, OutputConfig } from "@buildnbuzz/buzzform"; type SaveStatus = "idle" | "saving" | "saved"; @@ -36,6 +36,7 @@ type BuilderState = { viewport: Viewport; formId: string; formName: string; + outputConfig?: OutputConfig; saveStatus: SaveStatus; lastSavedAt: number | null; }; @@ -55,6 +56,9 @@ type BuilderActions = { ) => void; selectNode: (id: string | null) => void; updateNode: (id: string, updates: Partial) => void; + updateFormSettings: ( + updates: Partial>, + ) => void; removeNode: (id: string) => void; duplicateNode: (id: string) => void; setActiveTab: (nodeId: string, slot: string) => void; @@ -65,7 +69,12 @@ type BuilderActions = { setZoom: (zoom: number) => void; setViewport: (viewport: Viewport) => void; clearState: () => void; - loadDocumentState: (state: Pick) => void; + loadDocumentState: ( + state: Pick< + BuilderState, + "nodes" | "rootIds" | "formId" | "formName" | "outputConfig" + >, + ) => void; setSaveStatus: (status: SaveStatus, timestamp?: number) => void; setFormName: (name: string) => void; setFormId: (id: string) => void; @@ -73,10 +82,10 @@ type BuilderActions = { type Store = BuilderState & BuilderActions; -type TrackedState = Pick; +type TrackedState = Pick; type PersistableDocumentState = Pick< BuilderState, - "nodes" | "rootIds" | "formId" | "formName" + "nodes" | "rootIds" | "formId" | "formName" | "outputConfig" >; const INITIAL_STATE: BuilderState = { @@ -91,6 +100,7 @@ const INITIAL_STATE: BuilderState = { viewport: "desktop", formId: nanoid(), formName: "Untitled form", + outputConfig: undefined, saveStatus: "idle", lastSavedAt: null, }; @@ -199,7 +209,9 @@ function syncTabsChildren( const nextName = typeof tab.name === "string" ? tab.name.trim() : ""; let sourceIndex = -1; - const nameMatchIndex = nextName ? previousNameToIndex.get(nextName) : undefined; + const nameMatchIndex = nextName + ? previousNameToIndex.get(nextName) + : undefined; if ( typeof nameMatchIndex === "number" && @@ -259,7 +271,10 @@ function syncTabsChildren( } } -function resolveDefaultTabIndex(tabs: TabsField["tabs"], defaultTab?: string | number) { +function resolveDefaultTabIndex( + tabs: TabsField["tabs"], + defaultTab?: string | number, +) { if (tabs.length === 0) return -1; if (typeof defaultTab === "number") { @@ -282,7 +297,10 @@ function sanitizeTabsDefaultTab(field: TabsField) { return; } - const currentDefaultIndex = resolveDefaultTabIndex(tabs, field.ui?.defaultTab); + const currentDefaultIndex = resolveDefaultTabIndex( + tabs, + field.ui?.defaultTab, + ); const isCurrentValid = currentDefaultIndex >= 0 && currentDefaultIndex < tabs.length && @@ -417,6 +435,14 @@ export const useBuilderStore = create()( }); }, + updateFormSettings: (updates) => { + set((state) => { + if ("outputConfig" in updates) { + state.outputConfig = updates.outputConfig; + } + }); + }, + removeNode: (id) => { set((state) => { const node = state.nodes[id]; @@ -575,6 +601,7 @@ export const useBuilderStore = create()( state.rootIds = [...documentState.rootIds]; state.formId = documentState.formId; state.formName = documentState.formName; + state.outputConfig = documentState.outputConfig; state.saveStatus = "saved"; state.lastSavedAt = Date.now(); }); @@ -593,10 +620,12 @@ export const useBuilderStore = create()( partialize: (state): TrackedState => ({ nodes: state.nodes, rootIds: state.rootIds, + outputConfig: state.outputConfig, }), equality: (pastState, currentState) => pastState.nodes === currentState.nodes && - pastState.rootIds === currentState.rootIds, + pastState.rootIds === currentState.rootIds && + pastState.outputConfig === currentState.outputConfig, limit: 50, handleSet: (handleSet) => (pastState) => { if (!pendingState) { @@ -625,6 +654,7 @@ export const useBuilderStore = create()( viewport: state.viewport, formId: state.formId, formName: state.formName, + outputConfig: state.outputConfig, }), version: 2, migrate: (persistedState, version) => { @@ -725,7 +755,8 @@ useBuilderStore.subscribe((state, prevState) => { state.nodes === prevState.nodes && state.rootIds === prevState.rootIds && state.formName === prevState.formName && - state.formId === prevState.formId + state.formId === prevState.formId && + state.outputConfig === prevState.outputConfig ) { return; } @@ -735,6 +766,7 @@ useBuilderStore.subscribe((state, prevState) => { rootIds: state.rootIds, formId: state.formId, formName: state.formName, + outputConfig: state.outputConfig, }; if (shouldSkipAutosave(snapshot, prevState)) { @@ -779,7 +811,10 @@ function shouldSkipAutosave( return previousEmpty || isNewFormSession; } -function isDocumentEmpty(nodes: Record, rootIds: string[]): boolean { +function isDocumentEmpty( + nodes: Record, + rootIds: string[], +): boolean { return rootIds.length === 0 && Object.keys(nodes).length === 0; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f0fb7d8..fd8d497 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -93,7 +93,7 @@ export default function RootLayout({ {children} - + diff --git a/apps/web/content/docs/form-component.mdx b/apps/web/content/docs/form-component.mdx index 05abde3..d88aa45 100644 --- a/apps/web/content/docs/form-component.mdx +++ b/apps/web/content/docs/form-component.mdx @@ -23,23 +23,24 @@ import { Form } from "@/components/buzzform/form"; ### Properties -| Property | Type | Default | Description | -| ------------------ | -------------------------------------- | ---------------- | -------------------------------------------- | -| `schema` | `BuzzFormSchema` | - | Schema from `createSchema()` | -| `fields` | `Field[]` | - | Override fields (if not using schema.fields) | -| `defaultValues` | `object \| function` | - | Initial form values | -| `onSubmit` | `(data) => void` | - | Submit handler | -| `mode` | `'onChange' \| 'onBlur' \| 'onSubmit'` | Provider default | Validation mode | -| `adapter` | `AdapterFactory` | Provider default | Form library adapter | -| `settings` | `FormSettings` | - | Form behavior settings | -| `disabled` | `boolean` | `false` | Disable entire form | -| `requireDirty` | `boolean` | `false` | Only allow submit when dirty | -| `disableIfInvalid` | `boolean` | `false` | Disable submit until valid | -| `showSubmit` | `boolean` | `true` | Show auto submit button | -| `submitLabel` | `string` | `"Submit"` | Submit button text | -| `submitClassName` | `string` | - | Submit button styling | -| `registry` | `FieldRegistry` | - | Custom field registry | -| `className` | `string` | - | Form container class | +| Property | Type | Default | Description | +| ------------------ | -------------------------------------- | ---------------- | ------------------------------------------------------ | +| `schema` | `BuzzFormSchema` | - | Schema from `createSchema()` | +| `fields` | `Field[]` | - | Override fields (if not using schema.fields) | +| `defaultValues` | `object \| function` | - | Initial form values | +| `onSubmit` | `(data) => void` | - | Submit handler | +| `mode` | `'onChange' \| 'onBlur' \| 'onSubmit'` | Provider default | Validation mode | +| `adapter` | `AdapterFactory` | Provider default | Form library adapter | +| `settings` | `FormSettings` | - | Form behavior settings | +| `disabled` | `boolean` | `false` | Disable entire form | +| `requireDirty` | `boolean` | `false` | Only allow submit when dirty | +| `disableIfInvalid` | `boolean` | `false` | Disable submit until valid | +| `showSubmit` | `boolean` | `true` | Show auto submit button | +| `submitLabel` | `string` | `"Submit"` | Submit button text | +| `submitClassName` | `string` | - | Submit button styling | +| `output` | `OutputConfig` | - | Output data shape ([docs](/docs/output-configuration)) | +| `registry` | `FieldRegistry` | - | Custom field registry | +| `className` | `string` | - | Form container class | ### Ref Access diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index ade1a45..09fa01b 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -16,6 +16,7 @@ "...fields", "---Reference---", "form-component", - "configuration" + "configuration", + "output-configuration" ] } diff --git a/apps/web/content/docs/output-configuration.mdx b/apps/web/content/docs/output-configuration.mdx new file mode 100644 index 0000000..b6ac10f --- /dev/null +++ b/apps/web/content/docs/output-configuration.mdx @@ -0,0 +1,99 @@ +--- +title: Output Configuration +description: Control the shape of form data passed to your submit handler. +--- + +# Output Configuration + +By default, BuzzForm passes form data to your `onSubmit` handler as **hierarchical JSON** matching your schema structure. You can configure the output to flatten nested data into path-delimited keys instead. + +## Default Behavior + +With no output configuration, data is passed as nested objects: + +```tsx + { + // data = { + // person: { + // name: "John", + // address: { street: "Main St", number: 5 } + // } + // } + }} +/> +``` + +## Path Output + +Set `output={{ type: "path" }}` to flatten nested data into dot-delimited path keys: + +```tsx + { + // data = { + // "person.name": "John", + // "person.address.street": "Main St", + // "person.address.number": 5 + // } + }} +/> +``` + +### Custom Delimiter + +Change the path delimiter with the `delimiter` option: + +```tsx + { + // data = { + // "person_name": "John", + // "person_address_street": "Main St", + // "person_address_number": 5 + // } + }} +/> +``` + +## API Reference + +### `OutputConfig` + +| Property | Type | Default | Description | +| ----------- | ------------------- | ------- | --------------------------------------------- | +| `type` | `'path'` | - | Flatten nested data into delimited path keys. | +| `delimiter` | `'.' \| '-' \| '_'` | `'.'` | Separator between path segments. | + +### Available Delimiters + +| Delimiter | Example Key | +| --------- | ----------------------- | +| `.` | `person.address.street` | +| `-` | `person-address-street` | +| `_` | `person_address_street` | + +## Provider-Level Config + +Set output configuration globally via `FormProvider`: + +```tsx + + {children} + +``` + +Individual forms can override the provider config: + +```tsx +// Uses provider's output config + + +// Overrides with custom delimiter + +``` diff --git a/apps/web/public/r/init.json b/apps/web/public/r/init.json index d69d5d9..2e818ce 100644 --- a/apps/web/public/r/init.json +++ b/apps/web/public/r/init.json @@ -13,7 +13,7 @@ "files": [ { "path": "registry/base/form.tsx", - "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport type {\r\n Field,\r\n FormAdapter,\r\n UseFormOptions,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport { useForm } from \"@buildnbuzz/buzzform\";\r\n\r\nimport {\r\n RenderFields,\r\n FieldRenderer,\r\n type FieldRegistry,\r\n} from \"@/components/buzzform/fields/render\";\r\nimport { cn } from \"@/lib/utils\";\r\nimport { Button } from \"@/components/ui/button\";\r\nimport { FieldGroup } from \"@/components/ui/field\";\r\n\r\n// Types\r\ntype ButtonProps = React.ComponentProps;\r\n\r\n// Context\r\ninterface FormContextValue> {\r\n form: FormAdapter;\r\n fields: readonly Field[];\r\n registry?: FieldRegistry;\r\n disabled: boolean;\r\n requireDirty: boolean;\r\n disableIfInvalid: boolean;\r\n}\r\n\r\nconst FormContext = React.createContext(null);\r\n\r\nfunction useFormContext>() {\r\n const ctx = React.useContext(FormContext);\r\n if (!ctx) throw new Error(\"useFormContext must be used within \");\r\n return ctx as FormContextValue;\r\n}\r\n\r\n// Form\r\ninterface FormProps<\r\n TData extends Record = Record,\r\n> extends UseFormOptions {\r\n fields?: readonly Field[];\r\n className?: string;\r\n children?: React.ReactNode;\r\n registry?: FieldRegistry;\r\n disabled?: boolean;\r\n requireDirty?: boolean;\r\n disableIfInvalid?: boolean;\r\n submitLabel?: string;\r\n submitClassName?: string;\r\n showSubmit?: boolean;\r\n}\r\n\r\nfunction Form>({\r\n schema,\r\n fields: explicitFields,\r\n defaultValues,\r\n onSubmit,\r\n mode,\r\n settings: explicitSettings,\r\n adapter,\r\n className,\r\n children,\r\n registry,\r\n disabled = false,\r\n requireDirty = false,\r\n disableIfInvalid = false,\r\n submitLabel,\r\n submitClassName,\r\n showSubmit = true,\r\n}: FormProps) {\r\n const settings = React.useMemo(() => {\r\n if (!explicitSettings && !requireDirty) return undefined;\r\n return {\r\n ...explicitSettings,\r\n submitOnlyWhenDirty:\r\n requireDirty || explicitSettings?.submitOnlyWhenDirty,\r\n };\r\n }, [explicitSettings, requireDirty]);\r\n\r\n const form = useForm({\r\n schema,\r\n defaultValues,\r\n onSubmit,\r\n mode,\r\n settings,\r\n adapter,\r\n });\r\n\r\n const fields: readonly Field[] = React.useMemo(() => {\r\n if (explicitFields) return explicitFields;\r\n if (schema && \"fields\" in schema) return schema.fields as readonly Field[];\r\n return [];\r\n }, [explicitFields, schema]);\r\n\r\n const contextValue = React.useMemo>(\r\n () => ({\r\n form,\r\n fields,\r\n registry,\r\n disabled,\r\n requireDirty,\r\n disableIfInvalid,\r\n }),\r\n [form, fields, registry, disabled, requireDirty, disableIfInvalid]\r\n );\r\n\r\n const content = children ?? (\r\n \r\n \r\n {showSubmit && (\r\n {submitLabel}\r\n )}\r\n \r\n );\r\n\r\n return (\r\n \r\n {content}\r\n \r\n );\r\n}\r\n\r\n// FormContent\r\nfunction FormContent({ className, ...props }: React.ComponentProps<\"form\">) {\r\n const { form } = useFormContext();\r\n return (\r\n \r\n );\r\n}\r\n\r\n// FormFields\r\nfunction FormFields({ className, ...props }: React.ComponentProps<\"div\">) {\r\n const { fields, form, registry } = useFormContext();\r\n return (\r\n \r\n \r\n \r\n );\r\n}\r\n\r\n// FormField\r\nfunction FormField({\r\n name,\r\n className,\r\n ...props\r\n}: React.ComponentProps<\"div\"> & { name: string }) {\r\n const { fields, form, registry } = useFormContext();\r\n const field = fields.find((f) => \"name\" in f && f.name === name);\r\n\r\n if (!field) {\r\n if (process.env.NODE_ENV === \"development\") {\r\n console.warn(`FormField: Field \"${name}\" not found in schema.`);\r\n }\r\n return null;\r\n }\r\n\r\n return (\r\n
\r\n \r\n
\r\n );\r\n}\r\n\r\ntype FormActionProps = ButtonProps;\r\n\r\nfunction FormAction({\r\n children,\r\n disabled: propDisabled,\r\n ...props\r\n}: FormActionProps) {\r\n const { form, disabled: formDisabled } = useFormContext();\r\n const { isSubmitting, isLoading } = form.formState;\r\n\r\n return (\r\n \r\n {children}\r\n \r\n );\r\n}\r\n\r\ntype FormSubmitProps = Omit & {\r\n disabled?: boolean;\r\n submittingText?: string;\r\n};\r\n\r\nfunction FormSubmit({\r\n children,\r\n className,\r\n disabled: propDisabled,\r\n variant,\r\n size,\r\n submittingText = \"Submitting...\",\r\n ...props\r\n}: FormSubmitProps) {\r\n const {\r\n form,\r\n disabled: formDisabled,\r\n requireDirty,\r\n disableIfInvalid,\r\n } = useFormContext();\r\n const { isSubmitting, isLoading, isDirty, isValid } = form.formState;\r\n\r\n const isDisabled =\r\n propDisabled ||\r\n formDisabled ||\r\n isSubmitting ||\r\n isLoading ||\r\n (requireDirty && !isDirty) ||\r\n (disableIfInvalid && !isValid);\r\n\r\n return (\r\n \r\n {isSubmitting ? submittingText : children || \"Submit\"}\r\n \r\n );\r\n}\r\n\r\ntype FormResetProps = Omit & {\r\n disabled?: boolean;\r\n};\r\n\r\nfunction FormReset({\r\n children,\r\n className,\r\n disabled: propDisabled,\r\n variant = \"outline\",\r\n size,\r\n ...props\r\n}: FormResetProps) {\r\n const { form, disabled: formDisabled } = useFormContext();\r\n const { isSubmitting, isDirty } = form.formState;\r\n\r\n return (\r\n form.reset()}\r\n disabled={propDisabled || formDisabled || isSubmitting || !isDirty}\r\n className={className}\r\n {...props}\r\n >\r\n {children || \"Reset\"}\r\n \r\n );\r\n}\r\n\r\n// FormActions\r\nfunction FormActions({\r\n className,\r\n align = \"end\",\r\n ...props\r\n}: React.ComponentProps<\"div\"> & {\r\n align?: \"start\" | \"center\" | \"end\" | \"between\";\r\n}) {\r\n return (\r\n \r\n );\r\n}\r\n\r\n// FormMessage\r\nfunction FormMessage({\r\n className,\r\n children,\r\n ...props\r\n}: React.ComponentProps<\"div\">) {\r\n const { form } = useFormContext();\r\n const rootError = form.formState.errors[\"\"] || form.formState.errors[\"root\"];\r\n const message =\r\n children || (typeof rootError === \"string\" ? rootError : null);\r\n\r\n if (!message) return null;\r\n\r\n return (\r\n \r\n {message}\r\n
\r\n );\r\n}\r\n\r\n// Exports\r\nexport {\r\n Form,\r\n FormContent,\r\n FormFields,\r\n FormField,\r\n FormSubmit,\r\n FormReset,\r\n FormAction,\r\n FormActions,\r\n FormMessage,\r\n useFormContext,\r\n type FormProps,\r\n};\r\n", + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport type { Field, FormAdapter, UseFormOptions } from \"@buildnbuzz/buzzform\";\r\nimport { useForm } from \"@buildnbuzz/buzzform\";\r\n\r\nimport {\r\n RenderFields,\r\n FieldRenderer,\r\n type FieldRegistry,\r\n} from \"@/components/buzzform/fields/render\";\r\nimport { cn } from \"@/lib/utils\";\r\nimport { Button } from \"@/components/ui/button\";\r\nimport { FieldGroup } from \"@/components/ui/field\";\r\n\r\n// Types\r\ntype ButtonProps = React.ComponentProps;\r\n\r\n// Context\r\ninterface FormContextValue> {\r\n form: FormAdapter;\r\n fields: readonly Field[];\r\n registry?: FieldRegistry;\r\n disabled: boolean;\r\n requireDirty: boolean;\r\n disableIfInvalid: boolean;\r\n}\r\n\r\nconst FormContext = React.createContext(null);\r\n\r\nfunction useFormContext>() {\r\n const ctx = React.useContext(FormContext);\r\n if (!ctx) throw new Error(\"useFormContext must be used within \");\r\n return ctx as unknown as FormContextValue;\r\n}\r\n\r\n// Form\r\ninterface FormProps<\r\n TData extends Record = Record,\r\n> extends UseFormOptions {\r\n fields?: readonly Field[];\r\n className?: string;\r\n children?: React.ReactNode;\r\n registry?: FieldRegistry;\r\n disabled?: boolean;\r\n requireDirty?: boolean;\r\n disableIfInvalid?: boolean;\r\n submitLabel?: string;\r\n submitClassName?: string;\r\n showSubmit?: boolean;\r\n}\r\n\r\nfunction Form>({\r\n schema,\r\n fields: explicitFields,\r\n defaultValues,\r\n onSubmit,\r\n mode,\r\n output,\r\n settings: explicitSettings,\r\n adapter,\r\n className,\r\n children,\r\n registry,\r\n disabled = false,\r\n requireDirty = false,\r\n disableIfInvalid = false,\r\n submitLabel,\r\n submitClassName,\r\n showSubmit = true,\r\n}: FormProps) {\r\n const settings = React.useMemo(() => {\r\n if (!explicitSettings && !requireDirty) return undefined;\r\n return {\r\n ...explicitSettings,\r\n submitOnlyWhenDirty:\r\n requireDirty || explicitSettings?.submitOnlyWhenDirty,\r\n };\r\n }, [explicitSettings, requireDirty]);\r\n\r\n const form = useForm({\r\n schema,\r\n defaultValues,\r\n onSubmit,\r\n mode,\r\n output,\r\n settings,\r\n adapter,\r\n });\r\n\r\n const fields: readonly Field[] = React.useMemo(() => {\r\n if (explicitFields) return explicitFields;\r\n if (schema && \"fields\" in schema) return schema.fields as readonly Field[];\r\n return [];\r\n }, [explicitFields, schema]);\r\n\r\n const contextValue = React.useMemo(\r\n () => ({\r\n form,\r\n fields,\r\n registry,\r\n disabled,\r\n requireDirty,\r\n disableIfInvalid,\r\n }),\r\n [form, fields, registry, disabled, requireDirty, disableIfInvalid],\r\n );\r\n\r\n const content = children ?? (\r\n \r\n \r\n {showSubmit && (\r\n {submitLabel}\r\n )}\r\n \r\n );\r\n\r\n return (\r\n \r\n {content}\r\n \r\n );\r\n}\r\n\r\n// FormContent\r\nfunction FormContent({ className, ...props }: React.ComponentProps<\"form\">) {\r\n const { form } = useFormContext();\r\n return (\r\n \r\n );\r\n}\r\n\r\n// FormFields\r\nfunction FormFields({ className, ...props }: React.ComponentProps<\"div\">) {\r\n const { fields, form, registry } = useFormContext();\r\n return (\r\n \r\n \r\n \r\n );\r\n}\r\n\r\n// FormField\r\nfunction FormField({\r\n name,\r\n className,\r\n ...props\r\n}: React.ComponentProps<\"div\"> & { name: string }) {\r\n const { fields, form, registry } = useFormContext();\r\n const field = fields.find((f) => \"name\" in f && f.name === name);\r\n\r\n if (!field) {\r\n if (process.env.NODE_ENV === \"development\") {\r\n console.warn(`FormField: Field \"${name}\" not found in schema.`);\r\n }\r\n return null;\r\n }\r\n\r\n return (\r\n
\r\n \r\n
\r\n );\r\n}\r\n\r\ntype FormActionProps = ButtonProps;\r\n\r\nfunction FormAction({\r\n children,\r\n disabled: propDisabled,\r\n ...props\r\n}: FormActionProps) {\r\n const { form, disabled: formDisabled } = useFormContext();\r\n const { isSubmitting, isLoading } = form.formState;\r\n\r\n return (\r\n \r\n {children}\r\n \r\n );\r\n}\r\n\r\ntype FormSubmitProps = Omit & {\r\n disabled?: boolean;\r\n submittingText?: string;\r\n};\r\n\r\nfunction FormSubmit({\r\n children,\r\n className,\r\n disabled: propDisabled,\r\n variant,\r\n size,\r\n submittingText = \"Submitting...\",\r\n ...props\r\n}: FormSubmitProps) {\r\n const {\r\n form,\r\n disabled: formDisabled,\r\n requireDirty,\r\n disableIfInvalid,\r\n } = useFormContext();\r\n const { isSubmitting, isLoading, isDirty, isValid } = form.formState;\r\n\r\n const isDisabled =\r\n propDisabled ||\r\n formDisabled ||\r\n isSubmitting ||\r\n isLoading ||\r\n (requireDirty && !isDirty) ||\r\n (disableIfInvalid && !isValid);\r\n\r\n return (\r\n \r\n {isSubmitting ? submittingText : children || \"Submit\"}\r\n \r\n );\r\n}\r\n\r\ntype FormResetProps = Omit & {\r\n disabled?: boolean;\r\n};\r\n\r\nfunction FormReset({\r\n children,\r\n className,\r\n disabled: propDisabled,\r\n variant = \"outline\",\r\n size,\r\n ...props\r\n}: FormResetProps) {\r\n const { form, disabled: formDisabled } = useFormContext();\r\n const { isSubmitting, isDirty } = form.formState;\r\n\r\n return (\r\n form.reset()}\r\n disabled={propDisabled || formDisabled || isSubmitting || !isDirty}\r\n className={className}\r\n {...props}\r\n >\r\n {children || \"Reset\"}\r\n \r\n );\r\n}\r\n\r\n// FormActions\r\nfunction FormActions({\r\n className,\r\n align = \"end\",\r\n ...props\r\n}: React.ComponentProps<\"div\"> & {\r\n align?: \"start\" | \"center\" | \"end\" | \"between\";\r\n}) {\r\n return (\r\n \r\n );\r\n}\r\n\r\n// FormMessage\r\nfunction FormMessage({\r\n className,\r\n children,\r\n ...props\r\n}: React.ComponentProps<\"div\">) {\r\n const { form } = useFormContext();\r\n const rootError = form.formState.errors[\"\"] || form.formState.errors[\"root\"];\r\n const message =\r\n children || (typeof rootError === \"string\" ? rootError : null);\r\n\r\n if (!message) return null;\r\n\r\n return (\r\n \r\n {message}\r\n \r\n );\r\n}\r\n\r\n// Exports\r\nexport {\r\n Form,\r\n FormContent,\r\n FormFields,\r\n FormField,\r\n FormSubmit,\r\n FormReset,\r\n FormAction,\r\n FormActions,\r\n FormMessage,\r\n useFormContext,\r\n type FormProps,\r\n};\r\n", "type": "registry:component", "target": "components/buzzform/form.tsx" }, diff --git a/apps/web/registry/base/form.tsx b/apps/web/registry/base/form.tsx index 1aff352..9f96817 100644 --- a/apps/web/registry/base/form.tsx +++ b/apps/web/registry/base/form.tsx @@ -1,11 +1,7 @@ "use client"; import * as React from "react"; -import type { - Field, - FormAdapter, - UseFormOptions, -} from "@buildnbuzz/buzzform"; +import type { Field, FormAdapter, UseFormOptions } from "@buildnbuzz/buzzform"; import { useForm } from "@buildnbuzz/buzzform"; import { @@ -35,7 +31,7 @@ const FormContext = React.createContext(null); function useFormContext>() { const ctx = React.useContext(FormContext); if (!ctx) throw new Error("useFormContext must be used within "); - return ctx as FormContextValue; + return ctx as unknown as FormContextValue; } // Form @@ -60,6 +56,7 @@ function Form>({ defaultValues, onSubmit, mode, + output, settings: explicitSettings, adapter, className, @@ -86,6 +83,7 @@ function Form>({ defaultValues, onSubmit, mode, + output, settings, adapter, }); @@ -96,7 +94,7 @@ function Form>({ return []; }, [explicitFields, schema]); - const contextValue = React.useMemo>( + const contextValue = React.useMemo( () => ({ form, fields, @@ -105,7 +103,7 @@ function Form>({ requireDirty, disableIfInvalid, }), - [form, fields, registry, disabled, requireDirty, disableIfInvalid] + [form, fields, registry, disabled, requireDirty, disableIfInvalid], ); const content = children ?? ( @@ -118,7 +116,7 @@ function Form>({ ); return ( - + {content} ); @@ -293,7 +291,7 @@ function FormActions({ align === "center" && "justify-center", align === "end" && "justify-end", align === "between" && "justify-between", - className + className, )} {...props} /> @@ -319,7 +317,7 @@ function FormMessage({ data-slot="form-message" className={cn( "rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive", - className + className, )} {...props} > diff --git a/packages/buzzform/src/hooks/use-form.ts b/packages/buzzform/src/hooks/use-form.ts index d9a6a41..d191d1d 100644 --- a/packages/buzzform/src/hooks/use-form.ts +++ b/packages/buzzform/src/hooks/use-form.ts @@ -1,72 +1,106 @@ -'use client'; - -import { useContext, useMemo } from 'react'; -import { FormConfigContext } from '../context/form-context'; -import type { UseFormOptions, FormAdapter, AdapterOptions, Field, FormSettings } from '../types'; +"use client"; + +import { useContext, useMemo } from "react"; +import { FormConfigContext } from "../context/form-context"; +import type { + UseFormOptions, + FormAdapter, + AdapterOptions, + Field, + FormSettings, + OutputConfig, +} from "../types"; +import { transformFormOutput } from "../lib"; /** * Extract default values from field definitions. */ -function extractDefaultsFromFields(fields?: Field[]): Record | undefined { - if (!fields || fields.length === 0) return undefined; - - const defaults: Record = {}; - let hasDefaults = false; - - for (const field of fields) { - if ('name' in field && 'defaultValue' in field && field.defaultValue !== undefined) { - defaults[field.name] = field.defaultValue; - hasDefaults = true; - } - // Handle nested fields - if ('fields' in field && Array.isArray(field.fields)) { - const nestedDefaults = extractDefaultsFromFields(field.fields); - if (nestedDefaults) { - Object.assign(defaults, nestedDefaults); - hasDefaults = true; - } - } +function extractDefaultsFromFields( + fields?: Field[], +): Record | undefined { + if (!fields || fields.length === 0) return undefined; + + const defaults: Record = {}; + let hasDefaults = false; + + for (const field of fields) { + if ( + "name" in field && + "defaultValue" in field && + field.defaultValue !== undefined + ) { + defaults[field.name] = field.defaultValue; + hasDefaults = true; } + // Handle nested fields + if ("fields" in field && Array.isArray(field.fields)) { + const nestedDefaults = extractDefaultsFromFields(field.fields); + if (nestedDefaults) { + Object.assign(defaults, nestedDefaults); + hasDefaults = true; + } + } + } - return hasDefaults ? defaults : undefined; + return hasDefaults ? defaults : undefined; } // Apply FormSettings to a FormAdapter function applySettings( - form: FormAdapter, - settings?: FormSettings + form: FormAdapter, + settings?: FormSettings, + outputConfig?: OutputConfig, ): FormAdapter { - if (!settings) return form; - - if (settings.submitOnlyWhenDirty) { - const originalHandleSubmit = form.handleSubmit; - - form.handleSubmit = (e?: React.FormEvent) => { - if (!form.formState.isDirty) { - e?.preventDefault?.(); - return; - } - return originalHandleSubmit(e); - }; - } - - if (settings) { - form.settings = settings; - } - - return form; + if (!settings && !outputConfig) return form; + + if (settings?.submitOnlyWhenDirty) { + const originalHandleSubmit = form.handleSubmit; + + form.handleSubmit = (e?: React.FormEvent) => { + if (!form.formState.isDirty) { + e?.preventDefault?.(); + return; + } + return originalHandleSubmit(e); + }; + } + + if (settings) { + form.settings = settings; + } + + // Wrap getValues and watch to return transformed output + if (outputConfig?.type === "path") { + const originalGetValues = form.getValues; + form.getValues = () => { + const values = originalGetValues(); + return transformFormOutput(values, outputConfig); + }; + + const originalWatch = form.watch; + form.watch = (name?: string): T => { + const values = originalWatch(name); + // Only transform when watching the entire form (name is undefined) + if (!name) { + return transformFormOutput(values, outputConfig) as T; + } + return values; + }; + } + + return form; } /** * Create a form instance with the specified options. * Uses adapter and resolver from FormProvider context unless overridden. - * + * * @example * const loginSchema = createSchema([ * { type: 'email', name: 'email', required: true }, * { type: 'password', name: 'password', required: true }, * ]); - * + * * const form = useForm({ * schema: loginSchema, * onSubmit: async (data) => { @@ -76,61 +110,78 @@ function applySettings( * submitOnlyWhenDirty: true, * }, * }); - * + * * return ( * * ... * * ); */ -export function useForm = Record>( - options: UseFormOptions -): FormAdapter { - const globalConfig = useContext(FormConfigContext); - - // Merge global config with overrides - const adapter = options.adapter ?? globalConfig?.adapter; - const resolverFn = globalConfig?.resolver; - const mode = options.mode ?? globalConfig?.mode ?? 'onChange'; - const reValidateMode = options.reValidateMode ?? globalConfig?.reValidateMode ?? 'onChange'; - - const resolver = options.schema && resolverFn ? resolverFn(options.schema) : undefined; - - // Extract default values (useMemo must be unconditional) - const schemaWithFields = options.schema as (typeof options.schema & { fields?: Field[] }) | undefined; - const fieldDefaults = useMemo( - () => extractDefaultsFromFields(schemaWithFields?.fields), - [schemaWithFields?.fields] - ); - const defaultValues = options.defaultValues ?? fieldDefaults; +export function useForm< + TData extends Record = Record, +>(options: UseFormOptions): FormAdapter { + const globalConfig = useContext(FormConfigContext); + + // Merge global config with overrides + const adapter = options.adapter ?? globalConfig?.adapter; + const resolverFn = globalConfig?.resolver; + const mode = options.mode ?? globalConfig?.mode ?? "onChange"; + const reValidateMode = + options.reValidateMode ?? globalConfig?.reValidateMode ?? "onChange"; + const outputConfig = options.output ?? globalConfig?.output; + + const resolver = + options.schema && resolverFn ? resolverFn(options.schema) : undefined; + + // Extract default values (useMemo must be unconditional) + const schemaWithFields = options.schema as + | (typeof options.schema & { fields?: Field[] }) + | undefined; + const fieldDefaults = useMemo( + () => extractDefaultsFromFields(schemaWithFields?.fields), + [schemaWithFields?.fields], + ); + const defaultValues = options.defaultValues ?? fieldDefaults; + + // Wrap onSubmit to apply output transformation before calling user's handler + const userOnSubmit = options.onSubmit; + const wrappedOnSubmit = + userOnSubmit && outputConfig?.type === "path" + ? (data: TData) => { + const transformed = transformFormOutput(data, outputConfig); + return userOnSubmit(transformed); + } + : userOnSubmit; - // Call adapter unconditionally to maintain hook order - const effectiveAdapter = adapter ?? ((_opts: AdapterOptions) => { - throw new Error('useForm: No adapter configured.'); + // Call adapter unconditionally to maintain hook order + const effectiveAdapter = + adapter ?? + ((_opts: AdapterOptions) => { + throw new Error("useForm: No adapter configured."); }); - const form = effectiveAdapter({ - defaultValues, - resolver, - mode, - reValidateMode, - onSubmit: options.onSubmit, - } as AdapterOptions) as FormAdapter; - - // Validation must happen after hooks - if (!options.schema) { - throw new Error( - 'useForm: schema is required. ' + - 'Use createSchema([...]) to create a schema from fields, or pass a Zod schema directly.' - ); - } + const form = effectiveAdapter({ + defaultValues, + resolver, + mode, + reValidateMode, + onSubmit: wrappedOnSubmit, + } as AdapterOptions) as FormAdapter; + + // Validation must happen after hooks + if (!options.schema) { + throw new Error( + "useForm: schema is required. " + + "Use createSchema([...]) to create a schema from fields, or pass a Zod schema directly.", + ); + } - if (!adapter) { - throw new Error( - 'useForm: No adapter configured. ' + - 'Either wrap your app in or pass adapter in options.' - ); - } + if (!adapter) { + throw new Error( + "useForm: No adapter configured. " + + "Either wrap your app in or pass adapter in options.", + ); + } - return applySettings(form, options.settings); + return applySettings(form, options.settings, outputConfig); } diff --git a/packages/buzzform/src/index.ts b/packages/buzzform/src/index.ts index f1bd5fb..a41f883 100644 --- a/packages/buzzform/src/index.ts +++ b/packages/buzzform/src/index.ts @@ -82,6 +82,9 @@ export type { export type { BuzzFormSchema, FormSettings, + OutputType, + PathDelimiter, + OutputConfig, FormConfig, UseFormOptions, } from "./types"; @@ -135,6 +138,9 @@ export { getArrayRowLabel, } from "./lib"; +// Output transformation +export { transformFormOutput } from "./lib"; + // Field style utilities export { getFieldWidthStyle } from "./lib"; diff --git a/packages/buzzform/src/lib/index.ts b/packages/buzzform/src/lib/index.ts index 5de37c9..545515c 100644 --- a/packages/buzzform/src/lib/index.ts +++ b/packages/buzzform/src/lib/index.ts @@ -1,31 +1,33 @@ export { - generateFieldId, - getNestedValue, - setNestedValue, - flattenNestedObject, - formatBytes, -} from './utils'; + generateFieldId, + getNestedValue, + setNestedValue, + flattenNestedObject, + formatBytes, +} from "./utils"; export { - // Field path utilities - getNestedFieldPaths, - countNestedErrors, - resolveFieldState, - getArrayRowLabel, - // Field style utilities - getFieldWidthStyle, - // Select option utilities - normalizeSelectOption, - getSelectOptionValue, - getSelectOptionLabel, - getSelectOptionLabelString, - isSelectOptionDisabled, - // Number utilities - clampNumber, - applyNumericPrecision, - formatNumberWithSeparator, - parseFormattedNumber, - // Date utilities - parseToDate, -} from './field'; + // Field path utilities + getNestedFieldPaths, + countNestedErrors, + resolveFieldState, + getArrayRowLabel, + // Field style utilities + getFieldWidthStyle, + // Select option utilities + normalizeSelectOption, + getSelectOptionValue, + getSelectOptionLabel, + getSelectOptionLabelString, + isSelectOptionDisabled, + // Number utilities + clampNumber, + applyNumericPrecision, + formatNumberWithSeparator, + parseFormattedNumber, + // Date utilities + parseToDate, +} from "./field"; +// Output transformation +export { transformFormOutput } from "./output"; diff --git a/packages/buzzform/src/lib/output.ts b/packages/buzzform/src/lib/output.ts new file mode 100644 index 0000000..2ad72b2 --- /dev/null +++ b/packages/buzzform/src/lib/output.ts @@ -0,0 +1,62 @@ +import type { OutputConfig } from "../types"; + +/** + * Transform form data into the configured output shape. + * + * NOTE: The input data (typically from RHF with GroupField) is already nested. + * - 'flat' / 'nested' → pass through as-is (already nested) + * - 'path' → flatten nested object into path-delimited keys + */ +export function transformFormOutput( + data: TData, + config?: OutputConfig, +): TData { + if (!config || config.type !== "path") { + return data; + } + + if (typeof data !== "object" || data === null) { + return data; + } + + return flattenToPathKeys( + data as Record, + config.delimiter ?? ".", + ) as TData; +} + +/** + * Flatten a nested object into single-level keys using a delimiter. + * Leaf values (primitives, arrays, Date, File, etc.) are NOT traversed. + */ +function flattenToPathKeys( + data: Record, + delimiter: string, + prefix = "", +): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(data)) { + const path = prefix ? `${prefix}${delimiter}${key}` : key; + + if (isPlainObject(value)) { + Object.assign( + result, + flattenToPathKeys(value as Record, delimiter, path), + ); + } else { + result[path] = value; + } + } + + return result; +} + +/** + * Check if a value is a plain object. + */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} diff --git a/packages/buzzform/src/types/form.ts b/packages/buzzform/src/types/form.ts index 75acdc6..d59f557 100644 --- a/packages/buzzform/src/types/form.ts +++ b/packages/buzzform/src/types/form.ts @@ -1,6 +1,6 @@ -import type { ZodSchema } from 'zod'; -import type { Field } from './field'; -import type { AdapterFactory, Resolver } from './adapter'; +import type { ZodSchema } from "zod"; +import type { Field } from "./field"; +import type { AdapterFactory, Resolver } from "./adapter"; // ============================================================================= // SCHEMA WITH FIELDS @@ -10,8 +10,10 @@ import type { AdapterFactory, Resolver } from './adapter'; * A Zod schema with field definitions attached. * Created by `createSchema([...])` for type inference and field rendering. */ -export type BuzzFormSchema = - ZodSchema & { fields: TFields }; +export type BuzzFormSchema< + TData = unknown, + TFields extends readonly Field[] = Field[], +> = ZodSchema & { fields: TFields }; // ============================================================================= // FORM SETTINGS @@ -22,19 +24,64 @@ export type BuzzFormSchema */ export interface FormConfig { - /** - * Adapter factory function. - * Determines which form library handles state management. - * - * @example - * import { useRhfAdapter } from '@buildnbuzz/buzzform/rhf'; - * adapter: useRhfAdapter - */ - adapter: AdapterFactory; - - /** - * Validation resolver factory. - * Converts a Zod schema into a form-compatible resolver. - * @default zodResolver - * - * @example - * import { zodResolver } from '@buildnbuzz/buzzform/resolvers/zod'; - * resolver: zodResolver - */ - resolver?: (schema: ZodSchema) => Resolver; - - /** - * Default validation mode for all forms. - * - 'onChange': Validate on every change (default) - * - 'onBlur': Validate when fields lose focus - * - 'onSubmit': Validate only on submit - * @default 'onChange' - */ - mode?: 'onChange' | 'onBlur' | 'onSubmit'; - - /** - * When to re-validate after initial validation error. - * - 'onChange': Re-validate on every change (default) - * - 'onBlur': Re-validate when fields lose focus - * - 'onSubmit': Re-validate only on submit - * @default 'onChange' - */ - reValidateMode?: 'onChange' | 'onBlur' | 'onSubmit'; + /** + * Adapter factory function. + * Determines which form library handles state management. + * + * @example + * import { useRhfAdapter } from '@buildnbuzz/buzzform/rhf'; + * adapter: useRhfAdapter + */ + adapter: AdapterFactory; + + /** + * Validation resolver factory. + * Converts a Zod schema into a form-compatible resolver. + * @default zodResolver + * + * @example + * import { zodResolver } from '@buildnbuzz/buzzform/resolvers/zod'; + * resolver: zodResolver + */ + resolver?: (schema: ZodSchema) => Resolver; + + /** + * Default validation mode for all forms. + * - 'onChange': Validate on every change (default) + * - 'onBlur': Validate when fields lose focus + * - 'onSubmit': Validate only on submit + * @default 'onChange' + */ + mode?: "onChange" | "onBlur" | "onSubmit"; + + /** + * When to re-validate after initial validation error. + * - 'onChange': Re-validate on every change (default) + * - 'onBlur': Re-validate when fields lose focus + * - 'onSubmit': Re-validate only on submit + * @default 'onChange' + */ + reValidateMode?: "onChange" | "onBlur" | "onSubmit"; + + /** + * Default output configuration for all forms. + * Controls the shape of data passed to onSubmit. + * Can be overridden per-form via UseFormOptions. + * @default undefined (hierarchical JSON) + */ + output?: OutputConfig; } // ============================================================================= @@ -101,13 +156,13 @@ export interface FormConfig { /** * Options passed to useForm hook. - * + * * @example * const loginSchema = createSchema([ * { type: 'email', name: 'email', required: true }, * { type: 'password', name: 'password', required: true }, * ]); - * + * * const form = useForm({ * schema: loginSchema, * onSubmit: async (data) => { @@ -116,58 +171,69 @@ export interface FormConfig { * }); */ export interface UseFormOptions> { - // ========================================================================= - // PRIMARY CONFIGURATION - // ========================================================================= - - /** - * Zod schema for validation. - * - Use `createSchema([...])` for schema with fields attached (recommended) - * - Use raw Zod schema for validation-only (manual field rendering) - */ - schema: ZodSchema | BuzzFormSchema; - - /** - * Default values for the form. - * If not provided and schema has fields, extracted from field definitions. - * Can be static, sync function, or async function. - */ - defaultValues?: TData | (() => TData) | (() => Promise); - - /** - * Form submission handler. - * Called with validated data after all validation passes. - */ - onSubmit?: (data: TData) => Promise | void; - - // ========================================================================= - // PROVIDER OVERRIDES - // ========================================================================= - - /** - * Override the adapter for this specific form. - * Uses provider's adapter if not specified. - */ - adapter?: AdapterFactory; - - /** - * Override the validation mode for this form. - * Uses provider's mode if not specified. - */ - mode?: 'onChange' | 'onBlur' | 'onSubmit'; - - /** - * Override the re-validation mode for this form. - * Uses provider's reValidateMode if not specified. - */ - reValidateMode?: 'onChange' | 'onBlur' | 'onSubmit'; - - // ========================================================================= - // BEHAVIOR - // ========================================================================= - - /** - * Form behavior settings. + // ========================================================================= + // PRIMARY CONFIGURATION + // ========================================================================= + + /** + * Zod schema for validation. + * - Use `createSchema([...])` for schema with fields attached (recommended) + * - Use raw Zod schema for validation-only (manual field rendering) + */ + schema: ZodSchema | BuzzFormSchema; + + /** + * Default values for the form. + * If not provided and schema has fields, extracted from field definitions. + * Can be static, sync function, or async function. + */ + defaultValues?: TData | (() => TData) | (() => Promise); + + /** + * Form submission handler. + * Called with validated data after all validation passes. + */ + onSubmit?: (data: TData) => Promise | void; + + // ========================================================================= + // PROVIDER OVERRIDES + // ========================================================================= + + /** + * Override the adapter for this specific form. + * Uses provider's adapter if not specified. + */ + adapter?: AdapterFactory; + + /** + * Override the validation mode for this form. + * Uses provider's mode if not specified. + */ + mode?: "onChange" | "onBlur" | "onSubmit"; + + /** + * Override the re-validation mode for this form. + * Uses provider's reValidateMode if not specified. + */ + reValidateMode?: "onChange" | "onBlur" | "onSubmit"; + + // ========================================================================= + // OUTPUT + // ========================================================================= + + /** + * Output configuration for this form. + * Overrides the provider-level output config. + * Controls the shape of data passed to onSubmit. + * @default undefined (defaults to hierarchical JSON if not specified here or in Provider) */ - settings?: FormSettings; + output?: OutputConfig; + // ========================================================================= + // BEHAVIOR + // ========================================================================= + + /** + * Form behavior settings. + */ + settings?: FormSettings; } diff --git a/packages/buzzform/src/types/index.ts b/packages/buzzform/src/types/index.ts index 1c120f9..794b48d 100644 --- a/packages/buzzform/src/types/index.ts +++ b/packages/buzzform/src/types/index.ts @@ -72,6 +72,9 @@ export type { export type { BuzzFormSchema, FormSettings, + OutputType, + PathDelimiter, + OutputConfig, FormConfig, UseFormOptions, } from "./form";