From 302dc78013d3e4dd91e695e5a1d68349086cafe6 Mon Sep 17 00:00:00 2001 From: Hannes Tiede <951466+toxsick@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:47:54 +0200 Subject: [PATCH 1/2] fix(uniform): omit disabled values before schema validation and submission --- docs/code-style-guide.md | 73 +++ packages/uniform/README.md | 46 +- packages/uniform/docs/disabled-fields.md | 29 ++ packages/uniform/docs/password-fields.md | 37 ++ packages/uniform/src/Form/Form.test.tsx | 411 ++++++++++++++++- .../src/Form/subcomponents/FormContext.tsx | 17 +- .../Form/subcomponents/FormResolver.test.tsx | 79 +++- .../src/Form/subcomponents/FormResolver.ts | 49 +- .../src/_examples/FormSubmission.stories.tsx | 117 +++++ .../FormSubmission.test.tsx.snap | 422 ++++++++++++++++++ .../disabledFields/disabledFields.test.ts | 147 ++++++ .../helpers/disabledFields/disabledFields.ts | 53 +++ packages/uniform/src/hooks/README.md | 23 + .../hooks/useUniformField/useUniformField.tsx | 2 +- 14 files changed, 1459 insertions(+), 46 deletions(-) create mode 100644 docs/code-style-guide.md create mode 100644 packages/uniform/docs/disabled-fields.md create mode 100644 packages/uniform/docs/password-fields.md create mode 100644 packages/uniform/src/helpers/disabledFields/disabledFields.test.ts create mode 100644 packages/uniform/src/helpers/disabledFields/disabledFields.ts create mode 100644 packages/uniform/src/hooks/README.md diff --git a/docs/code-style-guide.md b/docs/code-style-guide.md new file mode 100644 index 000000000..9409175a0 --- /dev/null +++ b/docs/code-style-guide.md @@ -0,0 +1,73 @@ +# Code style guide + +Write code so readers can follow its intent from top to bottom. Apply these +conventions to new and changed code alongside the root and package `AGENTS.md` +instructions and the repository's ESLint and Prettier configuration. + +## Keep each step easy to follow + +Use descriptive intermediate variables when nested calls, transformations, or +conditions make a call or return object harder to read. Keep simple expressions +inline; avoid introducing variables that only repeat an existing name. + +```ts +const validationInput = omitDisabledValues(validationValues, disabledFields); +const result = await validation.validateAsync(validationInput); + +// Schema defaults can restore disabled values, so filter the output too. +const submissionValues = omitDisabledValues(result.data ?? {}, disabledFields); + +return { + errors: result.errors ?? {}, + values: submissionValues, +}; +``` + +Move large schemas and conditional configuration out of JSX into named +variables. In tests, separate the operation being tested from its assertion +when combining them would hide the sequence. + +## Order return properties alphabetically + +List properties in returned objects alphabetically so readers can find them +consistently. Match that order in expected result objects where practical. +Compute values before the return when their expressions need explanation. + +Do not reorder spreads or expressions when doing so would change override +precedence or evaluation behavior. Refactor those cases explicitly first. + +## Separate logical stages + +Use blank lines between setup, transformations, validation, and returned +results. Keep closely related statements together. Add a short comment when +the transition or ordering has a non-obvious reason. + +Prefer a direct expression for a simple callback. Use a block when it contains +multiple steps or needs an explanatory comment, following the lint rules for +that file. + +## Preserve useful explanations + +Keep comments that explain intent, constraints, lifecycle ordering, or edge +cases. Update them when behavior changes. Avoid comments that merely restate +the next line of code. + +When relying on a dependency's internal API, explain why it is needed and what +must be checked when upgrading the dependency. Keep that explanation near the +access rather than scattering it across callers. + +Review larger docstrings whenever responsibilities or behavior change. Describe +the current implementation, including important caveats, while preserving +still-relevant context. Do not expand unrelated documentation just because the +file is being edited. + +For public props, use the repository's active-verb JSDoc convention: describe +what the prop does, identify callback triggers, and include useful defaults or +fallbacks. See the root `AGENTS.md` for the full convention. + +## Keep cleanup focused + +Prefer small local improvements over new abstractions for a single expression. +Preserve runtime behavior, public types, and helpful comments during readability +changes. Run focused tests and the checks required by the repository; do not +weaken lint rules or assertions to accommodate a refactor. diff --git a/packages/uniform/README.md b/packages/uniform/README.md index efc363451..fcf0ae5b4 100644 --- a/packages/uniform/README.md +++ b/packages/uniform/README.md @@ -1,39 +1,19 @@ # uniform -## Password inputs +React form components backed by React Hook Form, Veto schemas, and Pixels UI. -`Input` with `type="password"` masks the value and includes a keyboard-accessible -show/hide button by default. Toggling changes only visibility: the field name, -value, validation, and submission behavior remain the same. Pointer activation -keeps input focus; keyboard users can reach and activate the toggle normally. -Form resets hide the password again. +## Features -```tsx - -``` +- Ready-to-use fields with consistent labels and validation feedback +- Schema validation and dynamic client validation +- Field arrays, value transforms, and reset-aware hooks +- Form debugging with sensitive-value redaction -Set `hidePasswordToggle={true}` to keep a password input masked without a toggle. -Custom `endContent` appears alongside the toggle. Disabled inputs disable the -toggle, and `clearable` password inputs offer separate clear and reveal buttons. +## Documentation -## Sensitive debug values - -Password inputs are automatically redacted as `[REDACTED]` in Uniform's debug -viewer, its JSON copy output, and Uniform's submit debug logs. Showing a password -inside the input does not reveal it in debug output. - -Use `sensitive` on an ordinary `Input` for API tokens and other secrets: - -```tsx - - -``` - -Custom field components can pass `sensitive: true` to `useUniformField`. The -shared hook registers the field path with the form. Nested paths and array items -are supported. Sensitivity is retained for the form's lifetime, including when -an input is unmounted but its value remains in form state. - -Redaction changes diagnostic copies only. Form values, validation, and `onSubmit` -receive the original values. Application callbacks that log submitted data must -handle their own redaction. +- [Uniform compared with React Hook Form](../../docs/uniform-vs-rhf.md) +- [Using Veto in the monorepo](../../docs/veto-monorepo-usage.md) +- [Styling guide](../../docs/styling.md) +- [Disabled fields](docs/disabled-fields.md) +- [Hooks overview](src/hooks/README.md) +- [Password fields](docs/password-fields.md) diff --git a/packages/uniform/docs/disabled-fields.md b/packages/uniform/docs/disabled-fields.md new file mode 100644 index 000000000..62c1888e0 --- /dev/null +++ b/packages/uniform/docs/disabled-fields.md @@ -0,0 +1,29 @@ +# Disabled fields + +Uniform omits disabled field values before schema validation and submission. +The stored value is retained and restored when the field is re-enabled. + +The Veto schema remains authoritative: a required disabled field produces a +missing-field error and blocks submission. Mark a field optional when its value +may be omitted: + +```tsx +const validation = veto({ accountCode: string().optional() }); + +
+ +
; +``` + +This applies to nested fields and client validation too. Schema defaults and +cross-field rules still run normally; disabling a field does not relax them. +Disabled values are also removed from validated output, so schema defaults +cannot add them to the submitted payload. + +For flat arrays, omitted entries keep their indices during validation. Submission +normalization turns these empty slots into `null` placeholders; later entries +keep their positions. + +Previously, Uniform validated stored disabled values before removing them from +submission. Required schemas must now accept omission, or the field must remain +enabled. Re-enabling restores validation of its retained value. diff --git a/packages/uniform/docs/password-fields.md b/packages/uniform/docs/password-fields.md new file mode 100644 index 000000000..1b244aec4 --- /dev/null +++ b/packages/uniform/docs/password-fields.md @@ -0,0 +1,37 @@ +# Password fields + +`Input` with `type="password"` masks the value and includes a keyboard-accessible +show/hide button by default. Toggling changes only visibility: the field name, +value, validation, and submission behavior remain the same. Pointer activation +keeps input focus; keyboard users can reach and activate the toggle normally. +Form resets hide the password again. + +```tsx + +``` + +Set `hidePasswordToggle={true}` to keep a password input masked without a toggle. +Custom `endContent` appears alongside the toggle. Disabled inputs disable the +toggle, and `clearable` password inputs offer separate clear and reveal buttons. + +## Sensitive debug values + +Password inputs are automatically redacted as `[REDACTED]` in Uniform's debug +viewer, its JSON copy output, and Uniform's submit debug logs. Showing a password +inside the input does not reveal it in debug output. + +Use `sensitive` on an ordinary `Input` for API tokens and other secrets: + +```tsx + + +``` + +Custom field components can pass `sensitive: true` to `useUniformField`. The +shared hook registers the field path with the form. Nested paths and array items +are supported. Sensitivity is retained for the form's lifetime, including when +an input is unmounted but its value remains in form state. + +Redaction changes diagnostic copies only. Form values, validation, and `onSubmit` +receive the original values. Application callbacks that log submitted data must +handle their own redaction. diff --git a/packages/uniform/src/Form/Form.test.tsx b/packages/uniform/src/Form/Form.test.tsx index 6316db60b..946704dda 100644 --- a/packages/uniform/src/Form/Form.test.tsx +++ b/packages/uniform/src/Form/Form.test.tsx @@ -1,12 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { string, veto } from '@fuf-stack/veto'; +import { array, object, objectLoose, string, veto } from '@fuf-stack/veto'; import '@testing-library/jest-dom/vitest'; +import { useClientValidation } from '../hooks/useClientValidation'; +import { useFormContext } from '../hooks/useFormContext'; +import Input from '../Input'; import Form from './Form'; describe('Form', () => { @@ -290,6 +293,410 @@ describe('Form', () => { }); }); + describe('disabled field submission', () => { + it.each([false, true])( + 'omits populated disabled fields and preserves enabled fields (schema: %s)', + async (withSchema) => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const validation = withSchema + ? veto({ + disabledField: string().optional(), + enabledField: string(), + }) + : undefined; + + render( +
+ + + +
, + ); + + expect( + screen.getByRole('textbox', { name: 'disabledField' }), + ).toBeDisabled(); + expect( + screen.getByRole('textbox', { name: 'disabledField' }), + ).toHaveValue('retained'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith( + { enabledField: 'included' }, + expect.anything(), + ), + ); + }, + ); + + it('reports a missing required disabled field even when its stored value is valid', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const validation = veto({ field: string().min(3) }); + const validate = vi.spyOn(validation, 'validateAsync'); + const form = (disabled: boolean) => ( +
+ + +
+ ); + const { rerender } = render(form(true)); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(screen.getByText('Field is required')).toBeVisible(), + ); + expect(validate).toHaveBeenLastCalledWith({}); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue( + 'valid', + ); + + rerender(form(false)); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith( + { field: 'valid' }, + expect.anything(), + ), + ); + }); + + it('submits the retained user value after a field is re-enabled', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const form = (disabled: boolean) => ( +
+ + +
+ ); + const { rerender } = render(form(false)); + const input = screen.getByRole('textbox', { name: 'field' }); + + await user.clear(input); + await user.type(input, 'updated'); + await user.tab(); + rerender(form(true)); + expect(input).toBeDisabled(); + expect(input).toHaveValue('updated'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({}, expect.anything()), + ); + + rerender(form(false)); + expect(input).toBeEnabled(); + expect(input).toHaveValue('updated'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit).toHaveBeenNthCalledWith( + 2, + { field: 'updated' }, + expect.anything(), + ); + }); + + it('omits an optional disabled value and validates its retained value when re-enabled', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const validation = veto({ + field: string().min(3, 'Value is too short').optional(), + }); + const form = (disabled: boolean) => ( +
+ + +
+ ); + const { rerender } = render(form(true)); + const input = screen.getByRole('textbox', { name: 'field' }); + + expect(input).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({}, expect.anything()), + ); + expect(screen.queryByText('Value is too short')).not.toBeInTheDocument(); + + rerender(form(false)); + expect(input).toBeEnabled(); + expect(input).toHaveValue('ab'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(screen.getByText('Value is too short')).toBeVisible(), + ); + expect(onSubmit).toHaveBeenCalledTimes(1); + + rerender(form(true)); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit).toHaveBeenNthCalledWith(2, {}, expect.anything()); + await waitFor(() => + expect( + screen.queryByText('Value is too short'), + ).not.toBeInTheDocument(), + ); + + rerender(form(false)); + await user.type(input, 'c'); + await user.tab(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(3)); + expect(onSubmit).toHaveBeenNthCalledWith( + 3, + { field: 'abc' }, + expect.anything(), + ); + }); + }); + + describe('disabled validation paths', () => { + it('omits a disabled sibling when only the enabled field is validated', async () => { + const user = userEvent.setup(); + const validation = veto({ + excluded: string().min(3).optional(), + enabled: string(), + }); + const validate = vi.spyOn(validation, 'validateAsync'); + const triggered = vi.fn(); + const ValidateEnabled = () => { + const { trigger } = useFormContext(); + return ( + + ); + }; + + render( +
+ + + + , + ); + + await user.click( + screen.getByRole('button', { name: 'Validate enabled' }), + ); + + await waitFor(() => expect(triggered).toHaveBeenCalledWith(true)); + // trigger('enabled') can succeed even if the resolver reports sibling errors; + // inspect the actual validation input to prove the sibling was omitted. + expect(validate).toHaveBeenLastCalledWith({ enabled: 'valid' }); + expect(screen.getByRole('textbox', { name: 'excluded' })).toHaveValue( + 'x', + ); + }); + + it.each([false, true])( + 'submits disabled flat-array entries as null placeholders (schema: %s)', + async (withSchema) => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const validation = withSchema + ? veto({ tags: array(string().min(3).optional()) }) + : undefined; + + render( +
+ + + + +
, + ); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith( + { tags: [null, null, 'valid'] }, + expect.anything(), + ), + ); + }, + ); + + it('retains exclusion across reset and validates the reset value when re-enabled', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const Reset = () => { + const { reset } = useFormContext(); + return ( + + ); + }; + const validation = veto({ + field: string().min(6, 'Value is too short').optional(), + }); + const form = (disabled: boolean) => ( +
+ + + + + ); + const { rerender } = render(form(true)); + await user.click(screen.getByRole('button', { name: 'Reset' })); + expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue( + 'reset', + ); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({}, expect.anything()), + ); + rerender(form(false)); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(screen.getByText('Value is too short')).toBeVisible(), + ); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, 'invalid'])( + 'omits optional disabled values before base and client validation (disabled value: %s)', + async (disabledValue) => { + const clientRule = vi.fn(async () => false); + const ClientRules = () => { + useClientValidation(true, () => + objectLoose({ + excluded: string() + .refine(clientRule, 'Client rule ran') + .nullish(), + }), + ); + return null; + }; + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const validation = veto({ + excluded: string().optional(), + enabled: string().min(3, 'Enabled value is too short'), + }); + + render( +
+ + + + + , + ); + const enabled = screen.getByRole('textbox', { name: 'enabled' }); + await user.type(enabled, 'ab'); + await user.tab(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(screen.getByText('Enabled value is too short')).toBeVisible(), + ); + expect(onSubmit).not.toHaveBeenCalled(); + expect(clientRule).not.toHaveBeenCalled(); + + await user.type(enabled, 'c'); + await user.tab(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith( + { enabled: 'abc' }, + expect.anything(), + ), + ); + expect(clientRule).not.toHaveBeenCalled(); + }, + ); + + it('excludes nested disabled fields without disabling the same field in other array rows', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const validation = veto({ + rows: array( + object({ + name: string().min(3, 'Name is too short').optional(), + code: string(), + }), + ), + }); + + render( +
+ + + +
, + ); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(screen.getByText('Name is too short')).toBeVisible(), + ); + expect(onSubmit).not.toHaveBeenCalled(); + await user.type( + screen.getByRole('textbox', { name: 'rows.1.name' }), + 'yz', + ); + await user.tab(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith( + { rows: [{ code: 'first' }, { name: 'xyz', code: 'second' }] }, + expect.anything(), + ), + ); + }); + }); + describe('validation', () => { it('should validate according to validation schema', async () => { const handleSubmit = vi.fn(); diff --git a/packages/uniform/src/Form/subcomponents/FormContext.tsx b/packages/uniform/src/Form/subcomponents/FormContext.tsx index 9cc50d41a..61796727b 100644 --- a/packages/uniform/src/Form/subcomponents/FormContext.tsx +++ b/packages/uniform/src/Form/subcomponents/FormContext.tsx @@ -6,7 +6,13 @@ import type { import type { BaseSyntheticEvent, ReactNode } from 'react'; import type { FieldValues, Path, SubmitHandler } from 'react-hook-form'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { FormProvider as HookFormProvider, useForm } from 'react-hook-form'; import createDebug from 'debug'; @@ -241,6 +247,7 @@ interface FormProviderProps { * FormProvider component provides: * - Veto validation schema context * - Resolver-backed validation error state + * - RHF control binding for disabled-value omission without changing schema rules * - Client validation schema management * - Submit handler with automatic data conversion and submission control with preventSubmit * - Form Debug Mode state @@ -271,7 +278,7 @@ const FormProvider: React.FC = ({ useExtendedValidation(baseValidation); // Create resolver from extended validation + reactive validation error state - const { resolver, validationErrors, validationErrorsHash } = + const { bindControl, resolver, validationErrors, validationErrorsHash } = useFormResolver(extendedValidation); // Initialize react hook form with the resolver @@ -283,6 +290,12 @@ const FormProvider: React.FC = ({ resolver, }); + // The resolver is created before useForm, so bind its control afterwards, + // before RHF's passive effects can trigger validation. + useLayoutEffect(() => { + return bindControl(methods.control); + }, [bindControl, methods.control]); + // Get isValid from React Hook Form's formState const isValid = methods.formState?.isValid; diff --git a/packages/uniform/src/Form/subcomponents/FormResolver.test.tsx b/packages/uniform/src/Form/subcomponents/FormResolver.test.tsx index 90403dcff..4aa972532 100644 --- a/packages/uniform/src/Form/subcomponents/FormResolver.test.tsx +++ b/packages/uniform/src/Form/subcomponents/FormResolver.test.tsx @@ -1,11 +1,33 @@ -import { describe, expect, it } from 'vitest'; +import type { VetoInstance } from '@fuf-stack/veto'; + +import { describe, expect, it, vi } from 'vitest'; + +import { useLayoutEffect } from 'react'; +import { useController, useForm } from 'react-hook-form'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { objectLoose, string, veto } from '@fuf-stack/veto'; +import { array, objectLoose, string, veto } from '@fuf-stack/veto'; import { useExtendedValidation, useFormResolver } from './FormResolver'; +// Exercise the resolver against RHF's real registration and disabled state. +const useRegisteredResolver = ( + validation: VetoInstance, + name: string, + disabled = true, +) => { + const { control } = useForm(); + useController({ control, disabled, name }); + + const state = useFormResolver(validation); + const { bindControl } = state; + + useLayoutEffect(() => bindControl(control), [bindControl, control]); + + return state; +}; + describe('useExtendedValidation', () => { it('returns undefined when no base and no client validation exists', () => { const { result } = renderHook(() => useExtendedValidation()); @@ -175,6 +197,59 @@ describe('useExtendedValidation', () => { }); describe('useFormResolver', () => { + it('omits disabled input and schema defaults from output without changing stored values', async () => { + const validation = veto({ + disabledField: string().default('schema default'), + enabledField: string().trim(), + }); + const validate = vi.spyOn(validation, 'validateAsync'); + const values = { disabledField: 'stored', enabledField: ' Ada ' }; + const { result } = renderHook(() => + useRegisteredResolver(validation, 'disabledField'), + ); + await act(async () => { + const resolved = await result.current.resolver?.(values); + + expect(resolved).toEqual({ + errors: {}, + values: { enabledField: 'Ada' }, + }); + }); + expect(validate).toHaveBeenLastCalledWith({ enabledField: ' Ada ' }); + expect(values).toEqual({ disabledField: 'stored', enabledField: ' Ada ' }); + const directValidation = validation.validate({ enabledField: 'Ada' }); + + expect(directValidation.data).toEqual({ + disabledField: 'schema default', + enabledField: 'Ada', + }); + }); + + it('maps disabled flat-array field names to normalized validation paths', async () => { + const validation = veto({ tags: array(string().min(3).optional()) }); + const { result, rerender } = renderHook( + ({ disabled }) => + useRegisteredResolver(validation, 'tags.0.__FLAT__', disabled), + { initialProps: { disabled: true } }, + ); + await act(async () => { + const resolved = await result.current.resolver?.({ + tags: [{ __FLAT__: 'x' }, { __FLAT__: 'valid' }], + }); + expect(resolved).toEqual({ + errors: {}, + values: { tags: [undefined, 'valid'] }, + }); + }); + rerender({ disabled: false }); + await act(async () => { + await result.current.resolver?.({ + tags: [{ __FLAT__: 'x' }, { __FLAT__: 'valid' }], + }); + }); + expect(result.current.validationErrors).toBeDefined(); + }); + it('returns undefined resolver when no validation is provided', () => { const { result } = renderHook(() => useFormResolver()); diff --git a/packages/uniform/src/Form/subcomponents/FormResolver.ts b/packages/uniform/src/Form/subcomponents/FormResolver.ts index 9f5401329..2edb0cb1d 100644 --- a/packages/uniform/src/Form/subcomponents/FormResolver.ts +++ b/packages/uniform/src/Form/subcomponents/FormResolver.ts @@ -3,13 +3,14 @@ import type { VetoInstance, VetoTypeAny, } from '@fuf-stack/veto'; -import type { FieldValues } from 'react-hook-form'; +import type { Control, FieldValues } from 'react-hook-form'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { and, veto } from '@fuf-stack/veto'; import { toValidationFormat } from '../../helpers'; +import omitDisabledValues from '../../helpers/disabledFields/disabledFields'; /** * Hook that manages client validation schemas state. @@ -147,14 +148,32 @@ interface ValidationErrorsState { * Creates a React Hook Form resolver from an extended validation instance and * stores its latest formatted Veto errors as React state. * + * Bind the owning RHF control before validation runs. Each resolver call omits + * disabled values from normalized input and validated output while preserving + * stored form values. The schema stays unchanged, so required fields can still + * report missing-value errors and defaults cannot restore disabled output. + * * Keeping the resolver errors in state lets Uniform context consumers re-render * when validation output changes, while the hash prevents repeated validations * with equivalent errors from causing unnecessary updates. * * @param extendedValidation - Extended validation instance from useExtendedValidation - * @returns Resolver function plus the latest validation errors and their hash + * @returns Resolver, validation error state, and a binder for the owning RHF control */ export const useFormResolver = (extendedValidation?: VetoInstance) => { + const control = useRef(null); + + // Resolver options only contain the triggered fields. Bind the complete RHF + // control so disabled siblings and registry changes after reset are visible. + const bindControl = useCallback((instance: Control) => { + control.current = instance; + return () => { + if (control.current === instance) { + control.current = null; + } + }; + }, []); + // Keep the latest resolver errors in React state so Uniform context consumers // re-render when validation output changes. The paired hash lets us skip the // state update when a resolver run returns equivalent errors. @@ -196,18 +215,36 @@ export const useFormResolver = (extendedValidation?: VetoInstance) => { return async (values: FieldValues) => { const validationValues = toValidationFormat(values) ?? {}; - const result = await extendedValidation.validateAsync(validationValues); + + // RHF exposes no public complete disabled registry; this relies on its + // internal control structure and needs checking when upgrading RHF. + // Use one snapshot even if a field changes state during async validation. + const disabledFields = [...(control.current?._names.disabled ?? [])]; + + // Omit values, not schema rules: required disabled fields must still fail. + const validationInput = omitDisabledValues( + validationValues, + disabledFields, + ); + const result = await extendedValidation.validateAsync(validationInput); updateValidationErrors(result.errors ?? undefined); - // Transform veto result to React Hook Form format + // Omit disabled values again because schema defaults or transforms may + // have added them back to the output. + const submissionValues = omitDisabledValues( + result.data ?? {}, + disabledFields, + ); + return { - values: result.data ?? {}, errors: result.errors ?? {}, + values: submissionValues, }; }; }, [extendedValidation, updateValidationErrors]); return { + bindControl, resolver, validationErrors: validationErrorsState.errors, validationErrorsHash: validationErrorsState.hash, diff --git a/packages/uniform/src/_examples/FormSubmission.stories.tsx b/packages/uniform/src/_examples/FormSubmission.stories.tsx index 9bf22296d..a00e96fcc 100644 --- a/packages/uniform/src/_examples/FormSubmission.stories.tsx +++ b/packages/uniform/src/_examples/FormSubmission.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; + import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from 'storybook/test'; @@ -63,6 +65,121 @@ const FormStateReadout = () => { ); }; +const disabledFieldValidation = veto({ + accountCode: string() + .min(3, 'Account code needs at least 3 characters') + .optional(), + name: string(), +}); +const requiredDisabledFieldValidation = veto({ + accountCode: string().min(3, 'Account code needs at least 3 characters'), + name: string(), +}); + +const DisabledFieldsExample = ({ + requireAccountCode = false, +}: { + requireAccountCode?: boolean; +}) => { + const [disabled, setDisabled] = useState(true); + const [submitted, setSubmitted] = useState | null>( + null, + ); + const validation = requireAccountCode + ? requiredDisabledFieldValidation + : disabledFieldValidation; + + return ( +
{ + setSubmitted(data); + }} + validation={validation} + validationTrigger="onSubmit" + > + + + + Submit +
{JSON.stringify(submitted)}
+ + + ); +}; + +/** Disabled values are omitted; the optional schema permits submission. */ +export const DisabledFields: Story = { + render: () => { + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox', { name: /Account code/ }); + expect(input).toBeDisabled(); + await userEvent.click(canvas.getByRole('button', { name: 'Submit form' })); + await waitFor(async () => { + return expect(canvas.getByTestId('submitted_values')).toHaveTextContent( + '{"name":"Ada"}', + ); + }); + + await userEvent.click(canvas.getByRole('button', { name: 'Enable field' })); + expect(input).toBeEnabled(); + expect(input).toHaveValue('ab'); + await userEvent.click(canvas.getByRole('button', { name: 'Submit form' })); + await waitFor(async () => { + return expect( + canvas.getByText('Account code needs at least 3 characters'), + ).toBeVisible(); + }); + await userEvent.type(input, 'c'); + await userEvent.tab(); + await userEvent.click(canvas.getByRole('button', { name: 'Submit form' })); + await waitFor(async () => { + return expect(canvas.getByTestId('submitted_values')).toHaveTextContent( + '{"accountCode":"abc","name":"Ada"}', + ); + }); + }, +}; + +/** Disabling a field does not relax a schema that requires its value. */ +export const RequiredDisabledField: Story = { + render: () => { + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: 'Submit form' })); + await waitFor(async () => { + await expect(canvas.getByText('Field is required')).toBeVisible(); + }); + await expect(canvas.getByTestId('submitted_values')).toHaveTextContent( + 'null', + ); + + await userEvent.click(canvas.getByRole('button', { name: 'Enable field' })); + const input = canvas.getByRole('textbox', { name: /Account code/ }); + await expect(input).toHaveValue('ab'); + await userEvent.type(input, 'c'); + await userEvent.tab(); + await userEvent.click(canvas.getByRole('button', { name: 'Submit form' })); + await waitFor(async () => { + await expect(canvas.getByTestId('submitted_values')).toHaveTextContent( + '{"accountCode":"abc","name":"Ada"}', + ); + }); + }, +}; + /** * Basic form submission. The `SubmitButton` is a native `type="submit"` button * inside the `
`, so submission is handled entirely by the browser: diff --git a/packages/uniform/src/_examples/__snapshots__/FormSubmission.test.tsx.snap b/packages/uniform/src/_examples/__snapshots__/FormSubmission.test.tsx.snap index 4d2005944..213458e83 100644 --- a/packages/uniform/src/_examples/__snapshots__/FormSubmission.test.tsx.snap +++ b/packages/uniform/src/_examples/__snapshots__/FormSubmission.test.tsx.snap @@ -1,5 +1,215 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Story Snapshots > DisabledFields 1`] = ` +
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+ + +
+        {"accountCode":"abc","name":"Ada"}
+      
+
+
+ Form State +
+ +
+
+
+ isValid +
+
+ true +
+
+ isSubmitting +
+
+ false +
+
+ isSubmitSuccessful +
+
+ true +
+
+ submitCount +
+
+ 3 +
+
+
+
+ +
+
+`; + exports[`Story Snapshots > FormSubmission 1`] = `
RemoteFormSubmission 1`] = `
`; +exports[`Story Snapshots > RequiredDisabledField 1`] = ` +
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+ + +
+        {"accountCode":"abc","name":"Ada"}
+      
+
+
+ Form State +
+ +
+
+
+ isValid +
+
+ true +
+
+ isSubmitting +
+
+ false +
+
+ isSubmitSuccessful +
+
+ true +
+
+ submitCount +
+
+ 2 +
+
+
+
+
+
+
+`; + exports[`Story Snapshots > SubmitWithEnter 1`] = `
{ + it('keeps all values when no fields are disabled', () => { + const values = { + name: 'Ada', + count: 0, + active: false, + empty: '', + nullable: null, + }; + + expect(omitDisabledValues(values, [])).toStrictEqual(values); + }); + + it('removes disabled properties while preserving enabled falsy values', () => { + const values = Object.freeze({ + excluded: 'stored', + count: 0, + active: false, + empty: '', + nullable: null, + }); + + expect(omitDisabledValues(values, ['excluded'])).toStrictEqual({ + count: 0, + active: false, + empty: '', + nullable: null, + }); + expect(values.excluded).toBe('stored'); + }); + + it('removes nested values without mutating frozen form state', () => { + const profile = Object.freeze({ email: 'ada@example.com', name: 'Ada' }); + const values = Object.freeze({ profile, enabled: 'keep' }); + + expect(omitDisabledValues(values, ['profile.email'])).toStrictEqual({ + profile: { name: 'Ada' }, + enabled: 'keep', + }); + expect(values.profile).toStrictEqual({ + email: 'ada@example.com', + name: 'Ada', + }); + }); + + it.each(['rows.1.name', 'rows[1].name'])( + 'only removes the targeted array-row property for %s', + (name) => { + const rows = Object.freeze([ + Object.freeze({ name: 'first', code: 'a' }), + Object.freeze({ name: 'second', code: 'b' }), + Object.freeze({ name: 'third', code: 'c' }), + ]); + const values = Object.freeze({ rows }); + + expect(omitDisabledValues(values, [name])).toStrictEqual({ + rows: [ + { name: 'first', code: 'a' }, + { code: 'b' }, + { name: 'third', code: 'c' }, + ], + }); + expect(values.rows[1]).toStrictEqual({ name: 'second', code: 'b' }); + }, + ); + + it.each(['tags.1', 'tags[1]', 'tags.1.__FLAT__', 'tags[1].__FLAT__'])( + 'removes an array value without shifting later indices for %s', + (name) => { + const values = Object.freeze({ + tags: Object.freeze(['first', 'excluded', 'third']), + }); + + const result = omitDisabledValues(values, [name]); + + expect(result).toHaveProperty('tags.length', 3); + expect(result).toHaveProperty('tags.0', 'first'); + expect(result).not.toHaveProperty('tags.1'); + expect(result).toHaveProperty('tags.2', 'third'); + expect(values.tags).toStrictEqual(['first', 'excluded', 'third']); + }, + ); + + it.each([ + ['tags.0.__FLAT__', 'tags.1.__FLAT__'], + ['tags.1.__FLAT__', 'tags.0.__FLAT__'], + ])('preserves every omitted array slot for %s then %s', (first, second) => { + const values = Object.freeze({ + tags: Object.freeze(['first', 'second', 'third']), + }); + + const result = omitDisabledValues(values, [first, second]); + + expect(result).toHaveProperty('tags.length', 3); + expect(result).not.toHaveProperty('tags.0'); + expect(result).not.toHaveProperty('tags.1'); + expect(result).toHaveProperty('tags.2', 'third'); + expect(values.tags).toStrictEqual(['first', 'second', 'third']); + }); + + it('ignores missing paths and paths through primitive or nullish values', () => { + const values = { + profile: { name: 'Ada' }, + nullable: null, + absent: undefined, + zero: 0, + active: false, + }; + + expect( + omitDisabledValues(values, [ + 'missing.child', + 'profile.missing', + 'profile.name.child', + 'nullable.child', + 'absent.child', + 'zero.child', + 'active.child', + ]), + ).toStrictEqual(values); + }); + + it('handles duplicate and overlapping parent/child paths in either order', () => { + const values = Object.freeze({ + profile: Object.freeze({ email: 'ada@example.com' }), + enabled: 'keep', + }); + + expect( + omitDisabledValues(values, ['profile', 'profile.email', 'profile']), + ).toStrictEqual({ enabled: 'keep' }); + expect( + omitDisabledValues(values, ['profile.email', 'profile']), + ).toStrictEqual({ enabled: 'keep' }); + expect(values.profile.email).toBe('ada@example.com'); + }); + + it('ignores inherited properties', () => { + const values = { enabled: 'keep' }; + + expect(omitDisabledValues(values, ['toString.name'])).toBe(values); + }); +}); diff --git a/packages/uniform/src/helpers/disabledFields/disabledFields.ts b/packages/uniform/src/helpers/disabledFields/disabledFields.ts new file mode 100644 index 000000000..b9c6e38f6 --- /dev/null +++ b/packages/uniform/src/helpers/disabledFields/disabledFields.ts @@ -0,0 +1,53 @@ +import type { FieldValues } from 'react-hook-form'; + +import { flatArrayKey } from '../nullishFields/nullishFields'; + +/** Removes one path by copying only its ancestors, leaving stored values and array indices intact. */ +const omitPath = ( + value: unknown, + [key, ...rest]: readonly string[], +): unknown => { + if ( + !value || + typeof value !== 'object' || + key === undefined || + !Object.getOwnPropertyDescriptor(value, key) + ) { + return value; + } + + // Preserve holes left by previously omitted array entries. + const copy = (Array.isArray(value) ? value.slice() : { ...value }) as Record< + string, + unknown + >; + + if (rest.length) { + copy[key] = omitPath(copy[key], rest); + } else { + // Delete rather than splice so later array fields keep their indices. + delete copy[key]; + } + + return copy; +}; + +/** Omits disabled fields from normalized validation/submission data without changing form state. */ +const omitDisabledValues = ( + values: FieldValues, + names: readonly string[], +): FieldValues => { + return names.reduce((data, name) => { + // Match RHF names to normalized data, where flat-array wrappers are gone. + const path = name + .replace(/\[(\d+)\]/g, '.$1') + .split('.') + .filter((part) => { + return part !== flatArrayKey; + }); + + return omitPath(data, path) as FieldValues; + }, values); +}; + +export default omitDisabledValues; diff --git a/packages/uniform/src/hooks/README.md b/packages/uniform/src/hooks/README.md new file mode 100644 index 000000000..539203153 --- /dev/null +++ b/packages/uniform/src/hooks/README.md @@ -0,0 +1,23 @@ +# Uniform hooks + +These hooks connect form components to React Hook Form and Uniform's validation, +value conversion, and change notifications. Most need a parent Uniform `Form`. + +| Hook | Purpose | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [useUniformField](useUniformField/useUniformField.tsx) | Combines registration, labels, validation feedback, test IDs, and value transforms for field components | +| [useUniformFieldArray](useUniformFieldArray/useUniformFieldArray.ts) | Adds flat-array support, initialization, reset handling, and animation state to RHF field arrays | +| [useController](useController/useController.ts) | Wraps RHF's controller with nullish-value conversion and user-change notifications | +| [useFormContext](useFormContext/useFormContext.ts) | Provides RHF methods with schema-aware field state, value conversion, and reset notifications | +| [useClientValidation](useClientValidation/useClientValidation.ts) | Registers dynamic client validation alongside the form's base schema | +| [useInput](useInput/useInput.ts) | Re-exports HeroUI's input hook | +| [useInputValueDebounce](useInputValueDebounce/useInputValueDebounce.ts) | Updates displayed input immediately, debounces stored changes, and flushes on blur | +| [useInputValueTransform](useInputValueTransform/useInputValueTransform.ts) | Converts between display values and stored form values | +| [useWatchFormReset](useWatchFormReset/useWatchFormReset.ts) | Runs a callback for resets made through Uniform's form context | +| [useWatchUserChange](useWatchUserChange/useWatchUserChange.ts) | Reacts to user edits without firing for programmatic value changes or resets | + +For custom fields, start with `useUniformField`. For ordinary form access, use +`useFormContext`. Form initialization uses React Hook Form's `useForm` internally; +Uniform does not add a separate hook with that name. + +See also [Uniform compared with React Hook Form](../../../../docs/uniform-vs-rhf.md). diff --git a/packages/uniform/src/hooks/useUniformField/useUniformField.tsx b/packages/uniform/src/hooks/useUniformField/useUniformField.tsx index 489ea7075..a4cec56ae 100644 --- a/packages/uniform/src/hooks/useUniformField/useUniformField.tsx +++ b/packages/uniform/src/hooks/useUniformField/useUniformField.tsx @@ -23,7 +23,7 @@ export interface UseUniformFieldParams< name: Path & string; /** Custom aria-label for accessibility. If not provided, falls back to field name when no visible label exists */ ariaLabel?: string; - /** Disable the field */ + /** Disables the field and omits its value from validation data and submission */ disabled?: boolean; /** Normalize nested RHF errors into a flat array for array-like fields */ isArrayValue?: boolean; From d716e37f05be3a2def3d2f3b3c9a7e48d0d9b21c Mon Sep 17 00:00:00 2001 From: Hannes Tiede <951466+toxsick@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:12:59 +0200 Subject: [PATCH 2/2] chore(uniform): remove docs links from Readme --- packages/uniform/README.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/uniform/README.md b/packages/uniform/README.md index fcf0ae5b4..918adaa52 100644 --- a/packages/uniform/README.md +++ b/packages/uniform/README.md @@ -1,6 +1,6 @@ # uniform -React form components backed by React Hook Form, Veto schemas, and Pixels UI. +React form components backed by React Hook Form, Veto schemas and Pixels UI. ## Features @@ -8,12 +8,3 @@ React form components backed by React Hook Form, Veto schemas, and Pixels UI. - Schema validation and dynamic client validation - Field arrays, value transforms, and reset-aware hooks - Form debugging with sensitive-value redaction - -## Documentation - -- [Uniform compared with React Hook Form](../../docs/uniform-vs-rhf.md) -- [Using Veto in the monorepo](../../docs/veto-monorepo-usage.md) -- [Styling guide](../../docs/styling.md) -- [Disabled fields](docs/disabled-fields.md) -- [Hooks overview](src/hooks/README.md) -- [Password fields](docs/password-fields.md)