From 916de940bc2014bc4f81c1e3cec3f71a5d02fa7f Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Fri, 23 Jan 2026 11:12:01 -0800 Subject: [PATCH 1/8] connected form group passthrough to useField, ai tests that I am sure will break --- .../src/ConnectedForm/ConnectedFormGroup.tsx | 9 +- .../ConnectedForm/__tests__/useField.test.tsx | 204 ++++++++++++++++++ packages/gamut/src/ConnectedForm/utils.tsx | 15 +- 3 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx index 90944d44aed..de15efcf329 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx @@ -2,6 +2,7 @@ import { css } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useEffect } from 'react'; import * as React from 'react'; +import { RegisterOptions } from 'react-hook-form'; import { FormError, @@ -42,7 +43,10 @@ export interface ConnectedFormGroupProps /** * An object consisting of a `component` key to specify what ConnectedFormInput to render - the remaining key/value pairs are that components desired props. */ - field: Omit, 'name' | 'disabled'> & FieldProps; + field: Omit, 'name' | 'disabled'> & + FieldProps & { + customValidation?: RegisterOptions; + }; } export function ConnectedFormGroup({ @@ -60,11 +64,12 @@ export function ConnectedFormGroup({ isSoloField, infotip, }: ConnectedFormGroupProps) { + const { component: Component, customValidation, ...rest } = field; const { error, isFirstError, isDisabled, setError, validation } = useField({ name, disabled, + customValidation, }); - const { component: Component, ...rest } = field; useEffect(() => { if (customError) { diff --git a/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx new file mode 100644 index 00000000000..d0d5d922b71 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx @@ -0,0 +1,204 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent } from '@testing-library/dom'; +import { act, waitFor } from '@testing-library/react'; +import * as React from 'react'; + +import { createPromise } from '../../utils'; +import { ConnectedForm, ConnectedFormGroup } from '..'; +import { ConnectedInput } from '../ConnectedInputs/ConnectedInput'; + +const mockInputKey = 'email'; +const mockDefaultValue = ''; +const customErrorMessage = 'Please enter a valid email address'; +const customRequiredMessage = 'Email is required'; + +const TestFormWithCustomValidation: React.FC = () => { + return ( + <> + + + + ); +}; + +const TestFormWithBothValidations: React.FC = () => { + return ( + <> + + + + ); +}; + +const renderView = setupRtl(ConnectedForm, { + defaultValues: { + [mockInputKey]: mockDefaultValue, + }, + onSubmit: () => null, + children: , +}); + +const renderViewWithBothValidations = setupRtl(ConnectedForm, { + defaultValues: { + [mockInputKey]: mockDefaultValue, + }, + validationRules: { + [mockInputKey]: { + required: 'This field is required from form level', + }, + }, + onSubmit: () => null, + children: , +}); + +describe('ConnectedForm - useField', () => { + it('should apply custom validation pattern rules', async () => { + const api = createPromise<{}>(); + const onSubmit = async (values: {}) => api.resolve(values); + + const { view } = renderView({ onSubmit }); + + const input = view.getByRole('textbox') as HTMLInputElement; + + // Try to submit with invalid email + await act(async () => { + fireEvent.change(input, { target: { value: 'invalid-email' } }); + fireEvent.blur(input); + }); + + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); + + // Should show the custom pattern validation error + await waitFor(() => { + expect(view.getByText(customErrorMessage)).toBeInTheDocument(); + }); + }); + + it('should validate required fields with custom validation', async () => { + const api = createPromise<{}>(); + const onSubmit = async (values: {}) => api.resolve(values); + + const { view } = renderView({ onSubmit }); + + // Try to submit with empty field + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); + + // Should show the custom required validation error + await waitFor(() => { + expect(view.getByText(customRequiredMessage)).toBeInTheDocument(); + }); + }); + + it('should pass validation with valid input', async () => { + const api = createPromise<{}>(); + const onSubmit = async (values: {}) => api.resolve(values); + + const { view } = renderView({ onSubmit }); + + const input = view.getByRole('textbox') as HTMLInputElement; + + // Submit with valid email + await act(async () => { + fireEvent.change(input, { target: { value: 'test@example.com' } }); + fireEvent.blur(input); + }); + + await act(async () => { + fireEvent.submit(view.getByRole('button')); + await api.innerPromise; + }); + + const result = await api.innerPromise; + + // Should successfully submit with the correct value + expect(result).toEqual({ [mockInputKey]: 'test@example.com' }); + }); + + it('should merge form-level and custom validations', async () => { + const api = createPromise<{}>(); + const onSubmit = async (values: {}) => api.resolve(values); + + const { view } = renderViewWithBothValidations({ onSubmit }); + + const input = view.getByRole('textbox') as HTMLInputElement; + + // Try to submit with empty field - should trigger form-level required validation + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); + + await waitFor(() => { + expect( + view.getByText('This field is required from form level') + ).toBeInTheDocument(); + }); + + // Now test with value that fails custom minLength validation + await act(async () => { + fireEvent.change(input, { target: { value: 'abc' } }); + fireEvent.blur(input); + }); + + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); + + await waitFor(() => { + expect( + view.getByText('Email must be at least 5 characters') + ).toBeInTheDocument(); + }); + + // Finally test with valid value that passes both validations + await act(async () => { + fireEvent.change(input, { target: { value: 'abcdef' } }); + fireEvent.blur(input); + }); + + await act(async () => { + fireEvent.submit(view.getByRole('button')); + await api.innerPromise; + }); + + const result = await api.innerPromise; + + // Should successfully submit + expect(result).toEqual({ [mockInputKey]: 'abcdef' }); + }); + + it('should set isRequired to true when custom validation includes required', () => { + const { view } = renderView(); + + const input = view.getByRole('textbox') as HTMLInputElement; + expect(input).toHaveAttribute('aria-required', 'true'); + }); +}); diff --git a/packages/gamut/src/ConnectedForm/utils.tsx b/packages/gamut/src/ConnectedForm/utils.tsx index 95560991c7f..3ad2af6d8ca 100644 --- a/packages/gamut/src/ConnectedForm/utils.tsx +++ b/packages/gamut/src/ConnectedForm/utils.tsx @@ -150,9 +150,15 @@ export const useFormState = () => { interface useFieldProps extends SubmitContextProps { name: string; + customValidation?: RegisterOptions; } -export const useField = ({ name, disabled, loading }: useFieldProps) => { +export const useField = ({ + name, + disabled, + loading, + customValidation, +}: useFieldProps) => { // This is fixed in a later react-hook-form version: // https://github.com/react-hook-form/react-hook-form/issues/2887 // eslint-disable-next-line @typescript-eslint/unbound-method @@ -176,11 +182,16 @@ export const useField = ({ name, disabled, loading }: useFieldProps) => { loading, }); - const validation = + const formValidation = (validationRules && validationRules[name as keyof typeof validationRules]) ?? undefined; + const validation = + formValidation || customValidation + ? ({ ...formValidation, ...customValidation } as RegisterOptions) + : undefined; + const ref = register(name, validation); return { From 85b1420f4a89cddd3af19b6067eb5a2c9c5672ff Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Tue, 27 Jan 2026 10:48:42 -0800 Subject: [PATCH 2/8] explicitly passing validations between group and components to make sure we get the proper validations all the way up and down the tree --- .vscode/settings.json | 3 ++- .../gamut/src/ConnectedForm/ConnectedFormGroup.tsx | 12 ++++++++---- .../ConnectedInputs/ConnectedCheckbox.tsx | 2 ++ .../ConnectedForm/ConnectedInputs/ConnectedInput.tsx | 2 ++ .../ConnectedNestedCheckboxes/index.tsx | 3 ++- .../ConnectedForm/ConnectedInputs/ConnectedRadio.tsx | 2 ++ .../ConnectedInputs/ConnectedRadioGroup.tsx | 3 ++- .../ConnectedInputs/ConnectedRadioGroupInput.tsx | 8 ++++++-- .../ConnectedInputs/ConnectedSelect.tsx | 2 ++ .../ConnectedInputs/ConnectedTextArea.tsx | 2 ++ .../src/ConnectedForm/ConnectedInputs/types.tsx | 2 ++ .../src/ConnectedForm/__tests__/useField.test.tsx | 8 ++++---- packages/gamut/src/ConnectedForm/utils.tsx | 8 ++++---- 13 files changed, 40 insertions(+), 17 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 13c37b8648a..90e239ab34d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,5 +41,6 @@ }, "storyExplorer.storiesGlobs": "packages/styleguide/stories/**/*.stories.mdx", "jest.jestCommandLine": "node_modules/.bin/jest", - "nxConsole.generateAiAgentRules": true + "nxConsole.generateAiAgentRules": true, + "snyk.advanced.autoSelectOrganization": true } diff --git a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx index de15efcf329..17a0d799c56 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx @@ -45,7 +45,7 @@ export interface ConnectedFormGroupProps */ field: Omit, 'name' | 'disabled'> & FieldProps & { - customValidation?: RegisterOptions; + customValidations?: RegisterOptions; }; } @@ -64,11 +64,11 @@ export function ConnectedFormGroup({ isSoloField, infotip, }: ConnectedFormGroupProps) { - const { component: Component, customValidation, ...rest } = field; + const { component: Component, customValidations, ...rest } = field; const { error, isFirstError, isDisabled, setError, validation } = useField({ name, disabled, - customValidation, + customValidations, }); useEffect(() => { @@ -80,13 +80,16 @@ export function ConnectedFormGroup({ } }, [customError, name, setError]); + const required = + Boolean(validation?.required) || Boolean(customValidations?.required); + const renderedLabel = ( {label} @@ -104,6 +107,7 @@ export function ConnectedFormGroup({ {...(rest as any)} aria-describedby={errorId} aria-invalid={showError} + customValidations={customValidations} disabled={disabled} name={name} /> diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedCheckbox.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedCheckbox.tsx index a1cd1f0b8b6..cf3387e2586 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedCheckbox.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedCheckbox.tsx @@ -15,10 +15,12 @@ export const ConnectedCheckbox: React.FC = ({ name, onUpdate, spacing, + customValidations, }) => { const { isDisabled, control, validation, isRequired } = useField({ name, disabled, + customValidations, }); return ( diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedInput.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedInput.tsx index 3bf4216e7cb..9ec0c7615ad 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedInput.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedInput.tsx @@ -7,11 +7,13 @@ import { ConnectedInputProps } from './types'; export const ConnectedInput: React.FC = ({ disabled, name, + customValidations, ...rest }) => { const { error, isDisabled, ref, isRequired } = useField({ name, disabled, + customValidations, }); return ( diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index c42d3b288b2..964acba677a 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -15,11 +15,12 @@ import { export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps -> = ({ name, options, disabled, onUpdate, spacing }) => { +> = ({ name, options, disabled, onUpdate, spacing, customValidations }) => { const { isDisabled, control, validation, isRequired, getValues, setValue } = useField({ name, disabled, + customValidations, }); const defaultValue: string[] = getValues()[name]; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadio.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadio.tsx index 4e50eae1b93..7cd5f856c3b 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadio.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadio.tsx @@ -7,11 +7,13 @@ import { ConnectedRadioProps } from './types'; export const ConnectedRadio: React.FC = ({ disabled, name, + customValidations, ...rest }) => { const { error, isDisabled, ref } = useField({ name, disabled, + customValidations, }); return ( diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadioGroup.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadioGroup.tsx index 8c7b2d31a43..cf8965b1261 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadioGroup.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadioGroup.tsx @@ -7,9 +7,10 @@ import { ConnectedRadioGroupProps } from './types'; export const ConnectedRadioGroup: React.FC = ({ name, onChange, + customValidations, ...rest }) => { - const { setValue, isRequired } = useField({ name }); + const { setValue, isRequired } = useField({ name, customValidations }); return ( = ({ name, options, disabled, ...rest }) => { +> = ({ name, options, disabled, customValidations, ...rest }) => { return ( - + {options.map((elem) => { return ( = ({ disabled, name, + customValidations, ...rest }) => { const { error, isDisabled, ref, isRequired } = useField({ name, disabled, + customValidations, }); return ( diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedTextArea.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedTextArea.tsx index 5b5f17cc2af..8182b7fa5d1 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedTextArea.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedTextArea.tsx @@ -7,11 +7,13 @@ import { ConnectedTextAreaProps } from './types'; export const ConnectedTextArea: React.FC = ({ disabled, name, + customValidations, ...rest }) => { const { error, isDisabled, ref, isRequired } = useField({ name, disabled, + customValidations, }); return ( diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 87bf6f02138..b3cfe0ec97b 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react'; +import { RegisterOptions } from 'react-hook-form'; import { CheckboxLabelUnion, @@ -15,6 +16,7 @@ export interface BaseConnectedFieldProps { } export interface ConnectedFieldProps extends BaseConnectedFieldProps { name: string; + customValidations?: RegisterOptions; } export interface MinimalCheckboxProps diff --git a/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx index d0d5d922b71..26eb6f32065 100644 --- a/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx +++ b/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx @@ -12,13 +12,13 @@ const mockDefaultValue = ''; const customErrorMessage = 'Please enter a valid email address'; const customRequiredMessage = 'Email is required'; -const TestFormWithCustomValidation: React.FC = () => { +const TestFormWithCustomValidations: React.FC = () => { return ( <> { null, - children: , + children: , }); const renderViewWithBothValidations = setupRtl(ConnectedForm, { diff --git a/packages/gamut/src/ConnectedForm/utils.tsx b/packages/gamut/src/ConnectedForm/utils.tsx index 3ad2af6d8ca..d4ad31eede2 100644 --- a/packages/gamut/src/ConnectedForm/utils.tsx +++ b/packages/gamut/src/ConnectedForm/utils.tsx @@ -150,14 +150,14 @@ export const useFormState = () => { interface useFieldProps extends SubmitContextProps { name: string; - customValidation?: RegisterOptions; + customValidations?: RegisterOptions; } export const useField = ({ name, disabled, loading, - customValidation, + customValidations, }: useFieldProps) => { // This is fixed in a later react-hook-form version: // https://github.com/react-hook-form/react-hook-form/issues/2887 @@ -188,8 +188,8 @@ export const useField = ({ undefined; const validation = - formValidation || customValidation - ? ({ ...formValidation, ...customValidation } as RegisterOptions) + formValidation || customValidations + ? ({ ...formValidation, ...customValidations } as RegisterOptions) : undefined; const ref = register(name, validation); From 85aae6c6c4ab6ff50794078216201e46755ab16d Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Mon, 15 Jun 2026 09:26:38 -0700 Subject: [PATCH 3/8] cleaning up hanging required, debouncedField passes custom validations, cleaning up tests --- .vscode/settings.json | 3 +- .../src/ConnectedForm/ConnectedFormGroup.tsx | 5 +- .../ConnectedForm/ConnectedInputs/types.tsx | 5 +- .../ConnectedForm/__tests__/useField.test.tsx | 265 +++++++++++------- packages/gamut/src/ConnectedForm/utils.tsx | 5 +- 5 files changed, 172 insertions(+), 111 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 333bf02db44..735bc20e5ae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -38,6 +38,5 @@ }, "storyExplorer.storiesGlobs": "packages/styleguide/stories/**/*.stories.mdx", "jest.jestCommandLine": "node_modules/.bin/jest", - "nxConsole.generateAiAgentRules": true, - "snyk.advanced.autoSelectOrganization": true + "nxConsole.generateAiAgentRules": true } diff --git a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx index 46d287defe0..7754a541b92 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx @@ -80,16 +80,13 @@ export function ConnectedFormGroup({ } }, [customError, name, setError]); - const required = - Boolean(validation?.required) || Boolean(customValidations?.required); - const renderedLabel = ( {label} diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index b3cfe0ec97b..71b4f5f6a45 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -76,7 +76,10 @@ export type NestedConnectedCheckboxOption = Omit< }; export interface ConnectedNestedCheckboxesProps - extends Pick { + extends Pick< + BaseConnectedCheckboxProps, + 'name' | 'disabled' | 'spacing' | 'customValidations' + > { options: NestedConnectedCheckboxOption[]; onUpdate?: (values: string[]) => void; } diff --git a/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx index 26eb6f32065..123c4cc5de1 100644 --- a/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx +++ b/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx @@ -12,6 +12,9 @@ const mockDefaultValue = ''; const customErrorMessage = 'Please enter a valid email address'; const customRequiredMessage = 'Email is required'; +// ─── Custom field-level validations (no form-level rules) ──────────────────── +// All validation rules come exclusively from customValidations on the field. + const TestFormWithCustomValidations: React.FC = () => { return ( <> @@ -34,17 +37,25 @@ const TestFormWithCustomValidations: React.FC = () => { ); }; -const TestFormWithBothValidations: React.FC = () => { +const renderView = setupRtl(ConnectedForm, { + defaultValues: { + [mockInputKey]: mockDefaultValue, + }, + onSubmit: () => null, + children: , +}); + +// ─── customValidations overriding form-level rules (same key) ──────────────── +// Both the form and the field define the same rule key; the field-level value wins. + +const TestFormWithOverrideValidations: React.FC = () => { return ( <> { ); }; -const renderView = setupRtl(ConnectedForm, { +const renderViewWithOverrideValidations = setupRtl(ConnectedForm, { defaultValues: { [mockInputKey]: mockDefaultValue, }, + validationRules: { + [mockInputKey]: { + required: 'This field is required from form level', + }, + }, onSubmit: () => null, - children: , + children: , }); +// ─── Merging form-level and customValidations (different keys) ──────────────── +// Form provides `required`; the field adds `minLength`. Both rules are enforced. + +const TestFormWithBothValidations: React.FC = () => { + return ( + <> + + + + ); +}; + const renderViewWithBothValidations = setupRtl(ConnectedForm, { defaultValues: { [mockInputKey]: mockDefaultValue, @@ -77,128 +117,149 @@ const renderViewWithBothValidations = setupRtl(ConnectedForm, { }); describe('ConnectedForm - useField', () => { - it('should apply custom validation pattern rules', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); + describe('custom field-level validations (no form-level rules)', () => { + it('should apply custom validation pattern rules', async () => { + const api = createPromise<{}>(); + const onSubmit = async (values: {}) => api.resolve(values); - const { view } = renderView({ onSubmit }); + const { view } = renderView({ onSubmit }); - const input = view.getByRole('textbox') as HTMLInputElement; + const input = view.getByRole('textbox') as HTMLInputElement; - // Try to submit with invalid email - await act(async () => { - fireEvent.change(input, { target: { value: 'invalid-email' } }); - fireEvent.blur(input); - }); + // Try to submit with invalid email + await act(async () => { + fireEvent.change(input, { target: { value: 'invalid-email' } }); + fireEvent.blur(input); + }); - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); - // Should show the custom pattern validation error - await waitFor(() => { - expect(view.getByText(customErrorMessage)).toBeInTheDocument(); + // Should show the custom pattern validation error + await waitFor(() => { + expect(view.getByText(customErrorMessage)).toBeInTheDocument(); + }); }); - }); - it('should validate required fields with custom validation', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); + it('should validate required fields with custom validation', async () => { + const api = createPromise<{}>(); + const onSubmit = async (values: {}) => api.resolve(values); - const { view } = renderView({ onSubmit }); + const { view } = renderView({ onSubmit }); - // Try to submit with empty field - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); + // Try to submit with empty field + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); - // Should show the custom required validation error - await waitFor(() => { - expect(view.getByText(customRequiredMessage)).toBeInTheDocument(); + // Should show the custom required validation error + await waitFor(() => { + expect(view.getByText(customRequiredMessage)).toBeInTheDocument(); + }); }); - }); - it('should pass validation with valid input', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); + it('should pass validation with valid input', async () => { + const api = createPromise<{}>(); + const onSubmit = async (values: {}) => api.resolve(values); - const { view } = renderView({ onSubmit }); + const { view } = renderView({ onSubmit }); - const input = view.getByRole('textbox') as HTMLInputElement; + const input = view.getByRole('textbox') as HTMLInputElement; - // Submit with valid email - await act(async () => { - fireEvent.change(input, { target: { value: 'test@example.com' } }); - fireEvent.blur(input); - }); + // Submit with valid email + await act(async () => { + fireEvent.change(input, { target: { value: 'test@example.com' } }); + fireEvent.blur(input); + }); - await act(async () => { - fireEvent.submit(view.getByRole('button')); - await api.innerPromise; - }); - - const result = await api.innerPromise; - - // Should successfully submit with the correct value - expect(result).toEqual({ [mockInputKey]: 'test@example.com' }); - }); + await act(async () => { + fireEvent.submit(view.getByRole('button')); + await api.innerPromise; + }); - it('should merge form-level and custom validations', async () => { - const api = createPromise<{}>(); - const onSubmit = async (values: {}) => api.resolve(values); - - const { view } = renderViewWithBothValidations({ onSubmit }); - - const input = view.getByRole('textbox') as HTMLInputElement; - - // Try to submit with empty field - should trigger form-level required validation - await act(async () => { - fireEvent.submit(view.getByRole('button')); - }); + const result = await api.innerPromise; - await waitFor(() => { - expect( - view.getByText('This field is required from form level') - ).toBeInTheDocument(); + // Should successfully submit with the correct value + expect(result).toEqual({ [mockInputKey]: 'test@example.com' }); }); - // Now test with value that fails custom minLength validation - await act(async () => { - fireEvent.change(input, { target: { value: 'abc' } }); - fireEvent.blur(input); - }); + it('should set isRequired to true when custom validation includes required', () => { + const { view } = renderView(); - await act(async () => { - fireEvent.submit(view.getByRole('button')); + const input = view.getByRole('textbox') as HTMLInputElement; + expect(input).toHaveAttribute('aria-required', 'true'); }); + }); - await waitFor(() => { - expect( - view.getByText('Email must be at least 5 characters') - ).toBeInTheDocument(); - }); + describe('customValidations overriding form-level rules (same key)', () => { + it('should give customValidations priority over form-level for the same key', async () => { + const { view } = renderViewWithOverrideValidations(); - // Finally test with valid value that passes both validations - await act(async () => { - fireEvent.change(input, { target: { value: 'abcdef' } }); - fireEvent.blur(input); - }); + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); - await act(async () => { - fireEvent.submit(view.getByRole('button')); - await api.innerPromise; + await waitFor(() => { + expect(view.getByText(customRequiredMessage)).toBeInTheDocument(); + expect( + view.queryByText('This field is required from form level') + ).not.toBeInTheDocument(); + }); }); - - const result = await api.innerPromise; - - // Should successfully submit - expect(result).toEqual({ [mockInputKey]: 'abcdef' }); }); - it('should set isRequired to true when custom validation includes required', () => { - const { view } = renderView(); - - const input = view.getByRole('textbox') as HTMLInputElement; - expect(input).toHaveAttribute('aria-required', 'true'); + describe('merging form-level and customValidations (different keys)', () => { + it('should merge form-level and custom validations', async () => { + const api = createPromise<{}>(); + const onSubmit = async (values: {}) => api.resolve(values); + + const { view } = renderViewWithBothValidations({ onSubmit }); + + const input = view.getByRole('textbox') as HTMLInputElement; + + // Try to submit with empty field - should trigger form-level required validation + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); + + await waitFor(() => { + expect( + view.getByText('This field is required from form level') + ).toBeInTheDocument(); + }); + + // Now test with value that fails custom minLength validation + await act(async () => { + fireEvent.change(input, { target: { value: 'abc' } }); + fireEvent.blur(input); + }); + + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); + + await waitFor(() => { + expect( + view.getByText('Email must be at least 5 characters') + ).toBeInTheDocument(); + }); + + // Finally test with valid value that passes both validations + await act(async () => { + fireEvent.change(input, { target: { value: 'abcdef' } }); + fireEvent.blur(input); + }); + + await act(async () => { + fireEvent.submit(view.getByRole('button')); + await api.innerPromise; + }); + + const result = await api.innerPromise; + + // Should successfully submit + expect(result).toEqual({ [mockInputKey]: 'abcdef' }); + }); }); }); diff --git a/packages/gamut/src/ConnectedForm/utils.tsx b/packages/gamut/src/ConnectedForm/utils.tsx index d4ad31eede2..75d2a17352c 100644 --- a/packages/gamut/src/ConnectedForm/utils.tsx +++ b/packages/gamut/src/ConnectedForm/utils.tsx @@ -355,7 +355,7 @@ type DebouncedFieldProps = Omit< GetInitialFormValueProps, 'setLocalValue' | 'defaultValue' > & - Pick & { + Pick & { type: T; shouldDirtyOnChange?: boolean; }; @@ -367,8 +367,9 @@ export function useDebouncedField({ loading, type, shouldDirtyOnChange, + customValidations, }: DebouncedFieldProps) { - const useFieldPayload = useField({ name, disabled, loading }); + const useFieldPayload = useField({ name, disabled, loading, customValidations }); const defaultValue = type === 'checkbox' ? false : ''; From ce8f72bca1a5698b993241ab1d0857392f33206b Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Mon, 15 Jun 2026 10:18:28 -0700 Subject: [PATCH 4/8] storybook docs --- .../ConnectedForm/ConnectedForm.mdx | 8 +++ .../ConnectedForm/ConnectedForm.stories.tsx | 64 +++++++++++++++++++ .../ConnectedFormInputs.mdx | 31 +++++++++ 3 files changed, 103 insertions(+) diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.mdx index a50662d9e0f..8eb454f7ed0 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.mdx @@ -120,6 +120,14 @@ Watched fields aren't usually great for performance, so only use these fields wh +### `customValidations` + +When validation rules need to depend on runtime values — such as one field's selection determining what's valid in another — each connected input accepts a `customValidations` prop. Unlike `validationRules` (which is memoized on mount), `customValidations` re-evaluates on every render, making it the right tool for rules that need to change based on component state. + +See ConnectedFormInputs for full usage details. + + + ## Playground diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 112f4e5279c..71653577603 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -257,3 +257,67 @@ export const WatchedFields = () => { ); }; + +const numbers = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; + +export const CustomFieldValidations = () => { + const [numberType, setNumberType] = useState('even'); + + // validationRules are memoized on mount, so the "must be even/odd" rule + // can't live there — it depends on numberType's runtime value. customValidations + // on the field prop re-evaluates each render, making this cross-field + // dependency possible. + const { ConnectedFormGroup, ConnectedForm, connectedFormProps } = + useConnectedForm({ + defaultValues: { numberType: 'even', number: '' }, + validationRules: { + numberType: { required: 'Please select a number type' }, + number: { required: 'Please select a number' }, + }, + watchedFields: { + fields: ['numberType'], + watchHandler: ([type]: string[]) => setNumberType(type), + }, + }); + + return ( + { + action('Form Submitted')(values); + }} + {...connectedFormProps} + > + + { + if (!value) return true; + const num = parseInt(value, 10); + if (numberType === 'even') { + return num % 2 === 0 || `${value} is odd — pick an even number`; + } + return num % 2 !== 0 || `${value} is even — pick an odd number`; + }, + }, + }} + label={`Pick an ${numberType} number`} + name="number" + /> + Submit + + ); +}; diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index 666eacdea38..b83a186a323 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -160,3 +160,34 @@ export const FormPage: React.FC = () => { ``` `useDebouncedField` should not be used with any varaint of the standard `ConnectedInput`, as it will clash with the default `useField` instance used internally by those components. + +## Field-level custom validations + +Form-level `validationRules` (passed to `useConnectedForm`) are memoized on mount and cannot change after the form is created. When you need validation rules that depend on runtime values — for example, when one field's selection determines what's valid in another field — pass `customValidations` directly to any connected input or to the `field` prop of `ConnectedFormGroup`. + +`customValidations` accepts the same [react-hook-form `RegisterOptions`](https://react-hook-form.com/docs/useform/register) as `validationRules`, re-evaluates on every render, and is merged with any matching form-level rules (with `customValidations` taking priority when both define the same key). + +```tsx +// The valid parity of `number` depends on `numberType`, a runtime value. +// That rule can't live in validationRules (memoized), so it goes in customValidations. +const [numberType, setNumberType] = useState('even'); + + { + const num = parseInt(value, 10); + return numberType === 'even' + ? num % 2 === 0 || `${value} is odd — pick an even number` + : num % 2 !== 0 || `${value} is even — pick an odd number`; + }, + }, + }} +/> +``` + +Use `watchedFields` (see ConnectedForm) to keep external state in sync with the field driving the conditional rule. From 59c899282d8e8a71af9019be09e77d4f43ff659c Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Mon, 15 Jun 2026 10:21:14 -0700 Subject: [PATCH 5/8] accessibility --- .../ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 71653577603..a0f1618b343 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -292,16 +292,16 @@ export const CustomFieldValidations = () => { { From 47dbf162801a4e7abe9939d61dea731d9e51bb69 Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Mon, 15 Jun 2026 10:25:17 -0700 Subject: [PATCH 6/8] format --- .../ConnectedInputs/ConnectedRadioGroupInput.tsx | 2 +- packages/gamut/src/ConnectedForm/utils.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadioGroupInput.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadioGroupInput.tsx index 0b0b3895463..6656cf4c71c 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadioGroupInput.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedRadioGroupInput.tsx @@ -8,8 +8,8 @@ export const ConnectedRadioGroupInput: React.FC< > = ({ name, options, disabled, customValidations, ...rest }) => { return ( {options.map((elem) => { diff --git a/packages/gamut/src/ConnectedForm/utils.tsx b/packages/gamut/src/ConnectedForm/utils.tsx index 75d2a17352c..2786ae29b0d 100644 --- a/packages/gamut/src/ConnectedForm/utils.tsx +++ b/packages/gamut/src/ConnectedForm/utils.tsx @@ -369,7 +369,12 @@ export function useDebouncedField({ shouldDirtyOnChange, customValidations, }: DebouncedFieldProps) { - const useFieldPayload = useField({ name, disabled, loading, customValidations }); + const useFieldPayload = useField({ + name, + disabled, + loading, + customValidations, + }); const defaultValue = type === 'checkbox' ? false : ''; From d36f2d8bd18e328faa6d748c8bc9502d48452d1f Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Mon, 15 Jun 2026 10:30:59 -0700 Subject: [PATCH 7/8] format 2 --- .../ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index b83a186a323..e7df153b6f3 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -187,7 +187,7 @@ const [numberType, setNumberType] = useState('even'); }, }, }} -/> +/>; ``` Use `watchedFields` (see ConnectedForm) to keep external state in sync with the field driving the conditional rule. From 590c94fb97aed14f0774684e5fcb633a044c84fe Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Mon, 15 Jun 2026 10:42:23 -0700 Subject: [PATCH 8/8] release plan --- .nx/version-plans/version-plan-1781545277210.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .nx/version-plans/version-plan-1781545277210.md diff --git a/.nx/version-plans/version-plan-1781545277210.md b/.nx/version-plans/version-plan-1781545277210.md new file mode 100644 index 00000000000..dbbb70a9453 --- /dev/null +++ b/.nx/version-plans/version-plan-1781545277210.md @@ -0,0 +1,5 @@ +--- +gamut: minor +--- + +Adding customValidations for Connected fields, allowing field level validations to overwrite form-level ones.