{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-proto-form-tanstack", "title": "useProtoForm for TanStack Form", "description": "TanStack Form adapter that preserves the native form API and adds protobuf messages, update masks, oneofs, and Connect/gRPC field violations.", "dependencies": [ "@bufbuild/protobuf", "@connectrpc/connect", "@tanstack/react-form" ], "registryDependencies": [ "@protoform/protobuf-provider" ], "files": [ { "path": "registry/base-nova/protoform/hooks/use-proto-form-tanstack/index.ts", "content": "'use client';\n\nimport { isMessage, type DescMessage, type MessageShape } from '@bufbuild/protobuf';\nimport type { FieldMask } from '@bufbuild/protobuf/wkt';\nimport {\n type ConnectErrorContext,\n createUpdateMask as createDirtyUpdateMask,\n createProtoFormSchema,\n extractConnectErrorContext,\n extractFieldViolations,\n formValuesToProto,\n humanizeServerFieldError,\n type ProtoConversionOptions,\n protoPathToFormPath,\n} from '../../lib/protobuf-provider';\nimport { ConnectError } from '@connectrpc/connect';\nimport {\n type FormAsyncValidateOrFn,\n type FormOptions,\n type FormValidateOrFn,\n type ReactFormExtendedApi,\n type UpdateMetaOptions,\n useForm,\n} from '@tanstack/react-form';\nimport { useState } from 'react';\n\ntype FormValues = Record;\n\nexport type UseProtoFormReturn<\n Values extends FormValues,\n Desc extends DescMessage,\n TOnMount extends undefined | FormValidateOrFn,\n TOnChange extends undefined | FormValidateOrFn,\n TOnChangeAsync extends undefined | FormAsyncValidateOrFn,\n TOnBlur extends undefined | FormValidateOrFn,\n TOnBlurAsync extends undefined | FormAsyncValidateOrFn,\n TOnSubmit extends undefined | FormValidateOrFn,\n TOnDynamic extends undefined | FormValidateOrFn,\n TOnDynamicAsync extends undefined | FormAsyncValidateOrFn,\n TOnServer extends undefined | FormAsyncValidateOrFn,\n TSubmitMeta,\n> = ReactFormExtendedApi<\n Values,\n TOnMount,\n TOnChange,\n TOnChangeAsync,\n TOnBlur,\n TOnBlurAsync,\n TOnSubmit,\n FormAsyncValidateOrFn,\n TOnDynamic,\n TOnDynamicAsync,\n TOnServer,\n TSubmitMeta\n> & {\n createMessage: (values?: Values) => MessageShape;\n createUpdateMask: () => FieldMask;\n clearServerErrorContext: () => void;\n getNestedErrors: >(path: string) => T | undefined;\n serverErrorContext: ConnectErrorContext | undefined;\n setServerErrors: (error: unknown) => {\n context: ConnectErrorContext;\n handled: boolean;\n unmapped: { field: string; description: string }[];\n };\n setOneofValue: (\n path: string,\n oneofCase: string,\n value: unknown,\n options?: UpdateMetaOptions\n ) => void;\n};\n\nfunction composeSubmitAsyncValidator(\n nativeValidator: FormAsyncValidateOrFn | undefined,\n protoValidator: FormValidateOrFn\n): FormAsyncValidateOrFn {\n return async ({ value, formApi }) => {\n if (nativeValidator) {\n const nativeError = await formApi.runValidator({\n validate: nativeValidator,\n value: {\n formApi,\n validationSource: 'form',\n value,\n },\n type: 'validateAsync',\n });\n if (nativeError) {\n return nativeError;\n }\n }\n\n return await formApi.runValidator({\n validate: protoValidator,\n value: {\n formApi,\n validationSource: 'form',\n value,\n },\n type: 'validateAsync',\n });\n };\n}\n\nfunction setDirtyPath(target: Record, path: string) {\n const segments = path.replaceAll('[', '.').replaceAll(']', '').split('.').filter(Boolean);\n let current = target;\n for (const [index, segment] of segments.entries()) {\n if (index === segments.length - 1) {\n current[segment] = true;\n return;\n }\n const existing = current[segment];\n if (typeof existing === 'object' && existing !== null && !Array.isArray(existing)) {\n current = existing as Record;\n } else {\n const next: Record = {};\n current[segment] = next;\n current = next;\n }\n }\n}\n\nfunction dirtyFieldsFromMeta(fieldMeta: Record): Record {\n const dirtyFields: Record = {};\n for (const [path, meta] of Object.entries(fieldMeta)) {\n if (\n typeof meta === 'object' &&\n meta !== null &&\n Reflect.get(meta, 'isDirty') === true &&\n Reflect.get(meta, 'isDefaultValue') !== true\n ) {\n setDirtyPath(dirtyFields, path);\n }\n }\n return dirtyFields;\n}\n\nexport function useProtoForm<\n Desc extends DescMessage,\n Values extends FormValues,\n TOnMount extends undefined | FormValidateOrFn,\n TOnChange extends undefined | FormValidateOrFn,\n TOnChangeAsync extends undefined | FormAsyncValidateOrFn,\n TOnBlur extends undefined | FormValidateOrFn,\n TOnBlurAsync extends undefined | FormAsyncValidateOrFn,\n TOnSubmit extends undefined | FormValidateOrFn,\n TOnSubmitAsync extends undefined | FormAsyncValidateOrFn,\n TOnDynamic extends undefined | FormValidateOrFn,\n TOnDynamicAsync extends undefined | FormAsyncValidateOrFn,\n TOnServer extends undefined | FormAsyncValidateOrFn,\n TSubmitMeta,\n>(\n schema: Desc,\n options: FormOptions<\n Values,\n TOnMount,\n TOnChange,\n TOnChangeAsync,\n TOnBlur,\n TOnBlurAsync,\n TOnSubmit,\n TOnSubmitAsync,\n TOnDynamic,\n TOnDynamicAsync,\n TOnServer,\n TSubmitMeta\n > & {\n emptyRepeatedStringPolicies?: ProtoConversionOptions[\"emptyRepeatedStringPolicies\"];\n serverPathPrefix?: string;\n }\n): UseProtoFormReturn<\n Values,\n Desc,\n TOnMount,\n TOnChange,\n TOnChangeAsync,\n TOnBlur,\n TOnBlurAsync,\n TOnSubmit,\n TOnDynamic,\n TOnDynamicAsync,\n TOnServer,\n TSubmitMeta\n> {\n const {\n emptyRepeatedStringPolicies,\n serverPathPrefix,\n ...nativeOptions\n } = options;\n const conversionOptions: ProtoConversionOptions = {\n emptyRepeatedStringPolicies,\n };\n const protoSchema = createProtoFormSchema(\n schema,\n conversionOptions\n );\n const onSubmitAsyncValidator = composeSubmitAsyncValidator(\n nativeOptions.validators?.onSubmitAsync,\n protoSchema\n );\n const composedOptions = {\n ...nativeOptions,\n validators: {\n ...nativeOptions.validators,\n onSubmitAsync: onSubmitAsyncValidator,\n },\n } as unknown as FormOptions<\n Values,\n TOnMount,\n TOnChange,\n TOnChangeAsync,\n TOnBlur,\n TOnBlurAsync,\n TOnSubmit,\n FormAsyncValidateOrFn,\n TOnDynamic,\n TOnDynamicAsync,\n TOnServer,\n TSubmitMeta\n >;\n const form = useForm(composedOptions);\n const setDynamicFieldValue = form.setFieldValue as unknown as (\n path: string,\n value: unknown,\n options?: UpdateMetaOptions\n ) => void;\n const sourceMessage = isMessage(nativeOptions.defaultValues, schema)\n ? nativeOptions.defaultValues\n : undefined;\n const [serverErrorContext, setServerErrorContext] = useState();\n\n const setServerErrors = (error: unknown) => {\n const context = extractConnectErrorContext(error);\n setServerErrorContext(context);\n if (!(error instanceof ConnectError)) {\n return {\n context,\n handled: false,\n unmapped: [] as { field: string; description: string }[],\n };\n }\n\n let handled = false;\n const unmapped: { field: string; description: string }[] = [];\n for (const violation of extractFieldViolations(error)) {\n const serverPath = stripPrefix(violation.field, serverPathPrefix);\n const formPath = protoPathToFormPath(schema, serverPath);\n if (!formPath) {\n unmapped.push(violation);\n continue;\n }\n form.setFieldMeta(formPath, (currentMeta) => {\n const meta = currentMeta ?? {\n _arrayVersion: 0,\n _pendingValidationsCount: 0,\n errorMap: {},\n errorSourceMap: {},\n isBlurred: false,\n isDirty: false,\n isTouched: false,\n isValidating: false,\n };\n return {\n ...meta,\n errorMap: {\n ...meta.errorMap,\n onServer: humanizeServerFieldError(violation.description),\n },\n errorSourceMap: {\n ...meta.errorSourceMap,\n onServer: 'form',\n },\n isTouched: true,\n };\n });\n handled = true;\n }\n return { context, handled, unmapped };\n };\n const setOneofValue = (\n path: string,\n oneofCase: string,\n value: unknown,\n updateOptions?: UpdateMetaOptions\n ) => {\n const current = form.getFieldValue(path);\n const isOneof =\n current === undefined ||\n current === null ||\n (typeof current === 'object' && 'case' in current);\n if (!isOneof) {\n throw new Error(\n `setOneofValue(\"${path}\"): target is not a oneof field. Expected { case, value } shape. Use setFieldValue() for regular fields.`\n );\n }\n const previous = current as { case?: string } | undefined;\n if (previous?.case && previous.case !== oneofCase) {\n setDynamicFieldValue(path, { case: undefined, value: undefined });\n }\n setDynamicFieldValue(\n path,\n { case: oneofCase, value },\n { dontUpdateMeta: false, ...updateOptions }\n );\n };\n const getNestedErrors = >(\n path: string\n ): T | undefined => {\n const nested: Record = {};\n let found = false;\n const fieldErrors = form.getAllErrors().fields as Record;\n for (const [fieldPath, fieldError] of Object.entries(fieldErrors)) {\n if (!(fieldPath === path || fieldPath.startsWith(`${path}.`))) {\n continue;\n }\n const messages = fieldError.errors\n .map((error) =>\n typeof error === 'string'\n ? error\n : error && typeof error === 'object' && typeof Reflect.get(error, 'message') === 'string'\n ? String(Reflect.get(error, 'message'))\n : undefined\n )\n .filter((message): message is string => Boolean(message));\n if (messages.length === 0) {\n continue;\n }\n found = true;\n const relativePath = fieldPath === path ? [] : fieldPath.slice(path.length + 1).split('.');\n if (relativePath.length === 0) {\n return { message: messages.join('\\n') } as T;\n }\n setNestedMessage(nested, relativePath, messages.join('\\n'));\n }\n return found ? (nested as T) : undefined;\n };\n\n return Object.assign(form, {\n clearServerErrorContext: () => setServerErrorContext(undefined),\n createMessage: (values?: Values) =>\n formValuesToProto(\n schema,\n values ?? form.state.values,\n sourceMessage,\n conversionOptions\n ),\n createUpdateMask: () =>\n createDirtyUpdateMask(\n schema,\n dirtyFieldsFromMeta(form.state.fieldMeta as Record),\n form.state.values,\n nativeOptions.defaultValues\n ),\n getNestedErrors,\n serverErrorContext,\n setOneofValue,\n setServerErrors,\n });\n}\n\nfunction setNestedMessage(target: Record, path: string[], message: string) {\n let current = target;\n for (const [index, segment] of path.entries()) {\n if (index === path.length - 1) {\n current[segment] = { message };\n return;\n }\n const existing = current[segment];\n if (existing && typeof existing === 'object' && !Array.isArray(existing)) {\n current = existing as Record;\n } else {\n const nested: Record = {};\n current[segment] = nested;\n current = nested;\n }\n }\n}\n\nfunction stripPrefix(field: string, prefix?: string): string {\n if (!prefix) {\n return field;\n }\n const withDot = `${prefix}.`;\n return field.startsWith(withDot) ? field.slice(withDot.length) : field;\n}\n", "type": "registry:hook" } ], "type": "registry:hook" }