{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-proto-form-v8", "title": "Experimental useProtoForm for React Hook Form v8", "description": "Experimental React Hook Form v8 beta adapter with protobuf messages, update masks, oneofs, and Connect/gRPC field violations.", "dependencies": [ "@bufbuild/protobuf", "@connectrpc/connect", "react-hook-form-v8@npm:react-hook-form@8.0.0-beta.3" ], "registryDependencies": [ "@protoform/protoform-core", "@protoform/protobuf-provider" ], "files": [ { "path": "registry/base-nova/protoform/hooks/use-proto-form-v8/index.ts", "content": "export { protoPathToFormPath } from \"./proto-error-path.js\";\nexport type { FlattenProtoOneofs } from \"./proto-paths.js\";\nexport { createProtoResolver } from \"./proto-resolver.js\";\nexport {\n type ConnectErrorContext,\n type UseProtoFormOptions,\n type UseProtoFormReturn,\n useProtoForm,\n useProtoFormDefaults,\n} from \"./use-proto-form.js\";\n", "type": "registry:hook" }, { "path": "registry/base-nova/protoform/hooks/use-proto-form-v8/proto-error-path.ts", "content": "import type { DescMessage } from \"@bufbuild/protobuf\";\nimport { protoPathToFormPath as mapProtoPathToFormPath } from \"../../lib/protobuf-provider/index.js\";\n\n/** Compatibility export. New framework adapters import this helper from the protobuf package. */\nexport function protoPathToFormPath(\n schema: DescMessage,\n serverPath: string\n): string | null {\n return mapProtoPathToFormPath(schema, serverPath);\n}\n", "type": "registry:hook" }, { "path": "registry/base-nova/protoform/hooks/use-proto-form-v8/proto-paths.ts", "content": "/**\n * Type utilities that flatten protobuf oneof discriminated unions so that\n * react-hook-form's `Path` can resolve nested paths like\n * `delivery.value.signingSecretRef`.\n *\n * The problem: @bufbuild/protobuf generates oneofs as tagged unions:\n * { case: 'webhook'; value: WebhookDelivery }\n * | { case: 'queue'; value: QueueDelivery }\n * | { case: undefined; value?: undefined }\n *\n * react-hook-form's Path can't traverse this union: it sees conflicting\n * `value` types across branches and gives up. Our fix: flatten each oneof\n * into `{ case: string | undefined; value: WebhookDelivery | QueueDelivery | undefined }`\n * so RHF treats `value` as a single traversable type.\n */\n\n/**\n * Detects a protobuf oneof shape: a union that includes\n * `{ case: undefined; value?: undefined }` as one of its members.\n */\ntype IsProtoOneof = T extends { case: string; value: infer _V }\n ? { case: undefined; value?: undefined } extends T\n ? true\n : false\n : false;\n\n/**\n * Converts a union to an intersection via distributive conditional types.\n * Used to merge all oneof branch value types so that Path can resolve\n * properties from ANY branch, not just properties shared across ALL branches.\n */\ntype UnionToIntersection = (\n U extends unknown\n ? (k: U) => void\n : never\n) extends (k: infer I) => void\n ? I\n : never;\n\n/**\n * Extracts all non-undefined `value` types from a proto oneof union,\n * then collapses the union into a single flat object with:\n * case: all case string literals | undefined\n * value: intersection of all value types | undefined\n *\n * The intersection (not union) is critical: with a union, Path can only\n * resolve properties shared across ALL branches (often none). With an\n * intersection, Path can resolve properties from ANY branch, enabling\n * paths like `authConfig.auth.value.keyRef` without type casts.\n *\n * This intentionally drops the case-to-value correlation constraint.\n * Runtime validation via createProtoResolver still enforces correctness.\n */\ntype FlattenOneof = {\n case: T extends { case: infer C } ? C : never;\n value:\n | UnionToIntersection<\n T extends { case: string; value: infer V } ? V : never\n >\n | undefined;\n};\n\n/**\n * Recursively walks a type and flattens any proto oneof unions it finds.\n * Non-oneof fields pass through unchanged. Arrays and tuples are traversed.\n */\nexport type FlattenProtoOneofs = T extends (infer U)[]\n ? FlattenProtoOneofs[]\n : T extends object\n ? true extends IsProtoOneof\n ? { [K in keyof FlattenOneof]: FlattenProtoOneofs[K]> }\n : { [K in keyof T]: FlattenProtoOneofs }\n : T;\n", "type": "registry:hook" }, { "path": "registry/base-nova/protoform/hooks/use-proto-form-v8/proto-resolver.ts", "content": "import type {\n DescMessage,\n MessageShape,\n MessageValidType,\n} from \"@bufbuild/protobuf\";\nimport type { FormValues } from \"../../lib/core/index.js\";\nimport {\n humanizeValidationError,\n isGenericValidationMessage,\n type ProtoFormOptions,\n PROTO_FORM_ROOT_ERROR_KEY,\n validateFormValuesAgainstProtoSchema,\n} from \"../../lib/protobuf-provider/index.js\";\nimport { createDescriptorAwareStandardSchema } from \"../../lib/protobuf-provider/validation-schema.js\";\nimport {\n type FieldError,\n type FieldErrors,\n get,\n type Resolver,\n type ResolverOptions,\n set,\n} from \"react-hook-form-v8\";\n\nfunction validateFieldNatively(\n ref: HTMLInputElement,\n path: string,\n errors: Record\n) {\n if (!(\"reportValidity\" in ref)) {\n return;\n }\n const error = get(errors, path) as FieldError | undefined;\n ref.setCustomValidity(error?.message ?? \"\");\n ref.reportValidity();\n}\n\nfunction validateFieldsNatively(\n errors: Record,\n options: ResolverOptions\n) {\n for (const [path, field] of Object.entries(options.fields)) {\n if (field?.ref && \"reportValidity\" in field.ref) {\n validateFieldNatively(field.ref as HTMLInputElement, path, errors);\n continue;\n }\n field?.refs?.forEach((ref) => validateFieldNatively(ref, path, errors));\n }\n}\n\nfunction isFieldArrayRoot(names: string[], path: string): boolean {\n const escapedPath = path\n .replace(/[\\[\\]]/g, \"\")\n .replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n return names.some((name) =>\n name.replace(/[\\[\\]]/g, \"\").match(`^${escapedPath}\\\\.\\\\d+`)\n );\n}\n\nfunction toNestErrors(\n flatErrors: Record,\n options: ResolverOptions\n): FieldErrors {\n if (options.shouldUseNativeValidation) {\n validateFieldsNatively(flatErrors, options);\n }\n const nestedErrors: FieldErrors = {};\n for (const path of Object.keys(flatErrors)) {\n const field = get(options.fields, path) as\n | ResolverOptions[\"fields\"][string]\n | undefined;\n const error = Object.assign(flatErrors[path] ?? {}, {\n ref: field?.refs?.[0] ?? field?.ref,\n });\n if (isFieldArrayRoot(options.names ?? Object.keys(flatErrors), path)) {\n const fieldArrayError = { ...(get(nestedErrors, path) ?? {}) };\n set(fieldArrayError, \"root\", error);\n set(nestedErrors, path, fieldArrayError);\n } else {\n set(nestedErrors, path, error);\n }\n }\n return nestedErrors;\n}\n\nexport function createProtoResolver(\n desc: Desc,\n options: ProtoFormOptions = {},\n source?: MessageShape\n): Resolver> {\n const standardSchema = createDescriptorAwareStandardSchema(desc, options);\n\n return async (values, _context, resolverOptions) => {\n const validationResult = await validateFormValuesAgainstProtoSchema(\n desc,\n values,\n standardSchema,\n options,\n source\n );\n\n if (!validationResult.issues) {\n if (resolverOptions.shouldUseNativeValidation) {\n validateFieldsNatively({}, resolverOptions);\n }\n return {\n errors: {},\n values: validationResult.value,\n };\n }\n\n // Flatten errors, preferring custom CEL messages over generic constraint messages.\n // When a field has both (e.g., `required = true` -> \"value is required\" AND\n // a CEL -> \"Server URL is required.\"), keep the custom one.\n // Then humanize whatever remains as a safety net.\n const rawErrors: Record =\n {};\n for (const issue of validationResult.issues) {\n if (issue.path.length === 0) {\n continue;\n }\n const path = issue.path.join(\".\");\n const generic = isGenericValidationMessage(issue.message);\n const existing = rawErrors[path];\n if (!existing) {\n rawErrors[path] = { isGeneric: generic, message: issue.message };\n } else if (existing.isGeneric && !generic) {\n // Replace generic message with custom CEL message\n rawErrors[path] = { isGeneric: false, message: issue.message };\n }\n }\n\n const flatErrors: Record = {};\n for (const [path, entry] of Object.entries(rawErrors)) {\n flatErrors[path] = {\n message: humanizeValidationError(entry.message),\n type: \"validation\",\n };\n }\n\n const nestedErrors = toNestErrors(flatErrors, resolverOptions);\n const rootMessages = validationResult.issues\n .filter((issue) => issue.path.length === 0)\n .map((issue) => humanizeValidationError(issue.message));\n\n if (rootMessages.length > 0) {\n // Object.assign keeps the intersection type: react-hook-form's\n // FieldErrors \"root\" slot for index-signature form types cannot be\n // satisfied by an annotated object literal.\n return {\n errors: Object.assign({}, nestedErrors, {\n root: {\n message: rootMessages.join(\"\\n\"),\n type: \"validation\",\n },\n [PROTO_FORM_ROOT_ERROR_KEY]: {\n message: rootMessages[0],\n type: \"validation\",\n },\n }),\n values: {},\n };\n }\n\n return {\n errors: nestedErrors,\n values: {},\n };\n };\n}\n", "type": "registry:hook" }, { "path": "registry/base-nova/protoform/hooks/use-proto-form-v8/use-proto-form.ts", "content": "import {\n create,\n type DescMessage,\n isMessage,\n type MessageInitShape,\n type MessageShape,\n} from \"@bufbuild/protobuf\";\nimport type { FieldMask } from \"@bufbuild/protobuf/wkt\";\nimport { ConnectError } from \"@connectrpc/connect\";\nimport {\n type ConnectErrorContext,\n createUpdateMask as createDirtyUpdateMask,\n extractConnectErrorContext,\n extractFieldViolations,\n formValuesToProto,\n humanizeServerFieldError,\n type ProtoConversionOptions,\n} from \"../../lib/protobuf-provider/index.js\";\nimport { useState } from \"react\";\nimport {\n type FieldPath,\n type Path,\n type SetValueConfig,\n type UseFormProps,\n type UseFormReturn,\n useForm,\n} from \"react-hook-form-v8\";\n\nimport { protoPathToFormPath } from \"./proto-error-path.js\";\nimport type { FlattenProtoOneofs } from \"./proto-paths.js\";\nimport { createProtoResolver } from \"./proto-resolver.js\";\n\nexport type { ConnectErrorContext } from \"../../lib/protobuf-provider/index.js\";\n\n/** MessageShape with proto oneofs flattened so react-hook-form Path works. */\ntype FormShape = FlattenProtoOneofs<\n MessageShape\n>;\n\n/** Extract nested error shape for a given path (e.g. oneof error drilling). */\ntype NestedErrors = {\n [K in keyof T]?: T[K] extends object\n ? NestedErrors & { message?: string }\n : { message?: string };\n};\n\nexport interface UseProtoFormOptions\n extends Omit>, \"resolver\"> {\n /** Per-field repeated-string conversion overrides keyed by descriptor path. */\n emptyRepeatedStringPolicies?: ProtoConversionOptions[\"emptyRepeatedStringPolicies\"];\n /**\n * Strip a leading server-path prefix before mapping server-side field\n * violations onto the form (e.g. `'notification'` when the RPC wraps the\n * message in `CreateNotificationRequest { notification: Notification }`).\n */\n serverPathPrefix?: string;\n}\n\nexport type UseProtoFormReturn = UseFormReturn<\n FormShape\n> & {\n /** Build a fully-typed protobuf message from current or provided form values. */\n createMessage: (values?: FormShape) => MessageShape;\n /** Build an AIP-safe FieldMask from the fields changed since the last reset. */\n createUpdateMask: () => FieldMask;\n /**\n * Set a oneof field value without casts. Marks the field dirty by default so\n * switching branches is visible to `dirtyFields`-driven FieldMask builders.\n * @example form.setOneofValue('delivery', 'webhook', create(WebhookDeliverySchema, { signingSecretRef: '' }));\n */\n setOneofValue: (\n path: string,\n oneofCase: string,\n value: unknown,\n options?: SetValueConfig\n ) => void;\n /** Drill nested errors by form path (e.g. `'delivery.value'`) without casts. */\n getNestedErrors: >(\n path: string\n ) => NestedErrors | undefined;\n /**\n * Map a `ConnectError` with `BadRequest.FieldViolation` details onto the form\n * by walking the proto descriptor. Snake_case field paths are converted to\n * camelCase; oneof branches flatten under `{oneofLocalName}.value`.\n *\n * Also extracts every other `google.rpc.*` detail (LocalizedMessage, Help,\n * ErrorInfo, RequestInfo, RetryInfo, DebugInfo, PreconditionFailure,\n * QuotaFailure, ResourceInfo) into `form.serverErrorContext` so the summary\n * can surface top-level message, help links, request ID, etc.\n *\n * Returns `unmapped` so the caller can fall back to a toast for field\n * violations the form can't surface, plus the full `context` for convenience.\n */\n setServerErrors: (error: unknown) => {\n context: ConnectErrorContext;\n handled: boolean;\n unmapped: { field: string; description: string }[];\n };\n /** Current backend error context (set by `setServerErrors`). Undefined when no recent error. */\n serverErrorContext: ConnectErrorContext | undefined;\n /** Clear `serverErrorContext`. Call when the user starts a new submit attempt. */\n clearServerErrorContext: () => void;\n};\n\n/**\n * Creates a react-hook-form instance with proto-driven validation.\n *\n * - Validation rules come from `buf.validate` annotations via `@bufbuild/protovalidate`.\n * - Oneofs are type-flattened so `register('config.value.apiKey')` works without casts.\n * - Default `mode: 'onChange'`.\n *\n * Caller handles submit / loading / summary. The hook derives update masks,\n * while `setServerErrors` turns backend `BadRequest.FieldViolation` details\n * into per-field form errors using descriptor metadata only.\n *\n * @example\n * ```tsx\n * const form = useProtoForm(NotificationSchema, {\n * defaultValues: create(NotificationSchema, { displayName, enabled: true }),\n * serverPathPrefix: 'notification',\n * });\n *\n * const onSubmit = async () => {\n * try {\n * const message = form.createMessage();\n * await mutation.mutateAsync(\n * create(CreateNotificationRequestSchema, { notification: message })\n * );\n * navigate(...);\n * } catch (error) {\n * const { handled, unmapped } = form.setServerErrors(error);\n * if (!handled || unmapped.length > 0) toast.error(...);\n * }\n * };\n * ```\n */\nexport function useProtoForm(\n schema: Desc,\n options?: UseProtoFormOptions\n): UseProtoFormReturn {\n const {\n emptyRepeatedStringPolicies,\n serverPathPrefix,\n mode = \"onChange\",\n ...rest\n } = options ?? {};\n const conversionOptions: ProtoConversionOptions = {\n emptyRepeatedStringPolicies,\n };\n const sourceMessage = isMessage(rest.defaultValues, schema)\n ? rest.defaultValues\n : undefined;\n\n const form = useForm({\n ...rest,\n mode,\n resolver: createProtoResolver(schema, conversionOptions, sourceMessage),\n } as unknown as UseFormProps>) as UseFormReturn<\n FormShape\n >;\n // Read during render so react-hook-form subscribes this hook to error updates.\n const formErrors = form.formState.errors;\n const dirtyFields = form.formState.dirtyFields;\n const initialValues = form.formState.defaultValues;\n const createMessage = (values?: FormShape): MessageShape => {\n const raw = values ?? form.getValues();\n return formValuesToProto(\n schema,\n raw as Record,\n sourceMessage,\n conversionOptions\n );\n };\n\n const createUpdateMask = (): FieldMask =>\n createDirtyUpdateMask(schema, dirtyFields, form.getValues(), initialValues);\n\n const setOneofValue = (\n path: string,\n oneofCase: string,\n value: unknown,\n setValueOptions?: SetValueConfig\n ) => {\n const current = form.getValues(path as 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. ` +\n \"Expected { case, value } shape. Use setValue() for regular fields.\"\n );\n }\n const prev = current as { case?: string; value?: unknown } | undefined;\n if (prev?.case && prev.case !== oneofCase) {\n form.setValue(\n path as Path>,\n { case: \"\", value: {} } as never\n );\n }\n // `shouldDirty: true` default: switching a branch is a meaningful edit.\n form.setValue(\n path as Path>,\n { case: oneofCase, value } as never,\n {\n shouldDirty: true,\n shouldValidate: true,\n ...setValueOptions,\n }\n );\n };\n\n const getNestedErrors = >(\n path: string\n ): NestedErrors | undefined => {\n const segments = path.split(\".\");\n let current: unknown = formErrors;\n for (const segment of segments) {\n if (current === undefined || current === null) {\n return;\n }\n current = (current as Record)[segment];\n }\n return current as NestedErrors | undefined;\n };\n\n const [serverErrorContext, setServerErrorContext] = useState<\n ConnectErrorContext | undefined\n >(undefined);\n const clearServerErrorContext = () => setServerErrorContext(undefined);\n\n const setServerErrors = (error: unknown) => {\n const context = extractConnectErrorContext(error);\n setServerErrorContext(context);\n\n if (!(error instanceof ConnectError)) {\n return {\n context,\n handled: false,\n unmapped: [] as { field: string; description: string }[],\n };\n }\n const unmapped: { field: string; description: string }[] = [];\n let handled = false;\n for (const violation of extractFieldViolations(error)) {\n const bare = stripPrefix(violation.field, serverPathPrefix);\n const formPath = protoPathToFormPath(schema, bare);\n if (!formPath) {\n unmapped.push(violation);\n continue;\n }\n form.setError(\n formPath as FieldPath>,\n {\n message: humanizeServerFieldError(violation.description),\n type: \"server\",\n },\n handled ? undefined : { shouldFocus: true }\n );\n handled = true;\n }\n return { context, handled, unmapped };\n };\n\n return Object.assign(form, {\n clearServerErrorContext,\n createMessage,\n createUpdateMask,\n getNestedErrors,\n serverErrorContext,\n setOneofValue,\n setServerErrors,\n });\n}\n\n/**\n * Type-safe default values helper. Wraps `create()` and returns the value typed\n * as `FormShape` so `defaultValues` compiles cleanly.\n */\nexport function useProtoFormDefaults(\n schema: Desc,\n init?: MessageInitShape\n): FormShape {\n return create(\n schema,\n init ?? ({} as MessageInitShape)\n ) as unknown as FormShape;\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" }