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. diff --git a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx index 97f17ca74c0..7754a541b92 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, FormGroup, FormGroupLabel, FormGroupProps } from '..'; import { Anchor } from '../Anchor'; @@ -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 & { + customValidations?: RegisterOptions; + }; } export function ConnectedFormGroup({ @@ -60,11 +64,12 @@ export function ConnectedFormGroup({ isSoloField, infotip, }: ConnectedFormGroupProps) { + const { component: Component, customValidations, ...rest } = field; const { error, isFirstError, isDisabled, setError, validation } = useField({ name, disabled, + customValidations, }); - const { component: Component, ...rest } = field; useEffect(() => { if (customError) { @@ -81,7 +86,7 @@ export function ConnectedFormGroup({ htmlFor={id || name} infotip={infotip} isSoloField={isSoloField} - required={!!validation?.required} + required={Boolean(validation?.required)} size={labelSize} > {label} @@ -99,6 +104,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 d5f25212aec..3d911f14e42 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedCheckbox.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedCheckbox.tsx @@ -16,10 +16,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..71b4f5f6a45 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 @@ -74,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 new file mode 100644 index 00000000000..b5c7e734410 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx @@ -0,0 +1,341 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent } from '@testing-library/dom'; +import { act, waitFor } from '@testing-library/react'; +import { useState } 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'; + +// ─── Custom field-level validations (no form-level rules) ──────────────────── +// All validation rules come exclusively from customValidations on the field. + +const TestFormWithCustomValidations: React.FC = () => { + return ( + <> + + + + ); +}; + +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 renderViewWithOverrideValidations = setupRtl(ConnectedForm, { + defaultValues: { + [mockInputKey]: mockDefaultValue, + }, + validationRules: { + [mockInputKey]: { + required: 'This field is required from form level', + }, + }, + onSubmit: () => null, + 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, + }, + validationRules: { + [mockInputKey]: { + required: 'This field is required from form level', + }, + }, + onSubmit: () => null, + children: , +}); + +// ─── Dynamic customValidations (rules changing after initial render) ────────── +// Validates that memoized `validation` updates when customValidations changes. + +const DynamicValidationsForm: React.FC = () => { + const [strict, setStrict] = useState(false); + + return ( + <> + + + + + ); +}; + +const renderViewWithDynamicValidations = setupRtl(ConnectedForm, { + defaultValues: { [mockInputKey]: mockDefaultValue }, + onSubmit: () => null, + children: , +}); + +describe('ConnectedForm - useField', () => { + 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 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 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('customValidations overriding form-level rules (same key)', () => { + it('should give customValidations priority over form-level for the same key', async () => { + const { view } = renderViewWithOverrideValidations(); + + await act(async () => { + fireEvent.submit(view.getByRole('button')); + }); + + await waitFor(() => { + expect(view.getByText(customRequiredMessage)).toBeInTheDocument(); + expect( + view.queryByText('This field is required from form level') + ).not.toBeInTheDocument(); + }); + }); + }); + + describe('dynamic customValidations (rules changing after initial render)', () => { + it('should enforce a rule added to customValidations after initial render', async () => { + const { view } = renderViewWithDynamicValidations(); + + const input = view.getByRole('textbox') as HTMLInputElement; + + // Enter a short value that would fail minLength — before the rule exists + await act(async () => { + fireEvent.change(input, { target: { value: 'short' } }); + fireEvent.blur(input); + }); + + await act(async () => { + fireEvent.submit(view.getByRole('button', { name: 'Submit' })); + }); + + // No minLength error yet — the rule hasn't been added + expect( + view.queryByText('Must be at least 10 characters') + ).not.toBeInTheDocument(); + + // Now enable the stricter rule + await act(async () => { + fireEvent.click(view.getByRole('button', { name: 'Enable strict' })); + }); + + await act(async () => { + fireEvent.submit(view.getByRole('button', { name: 'Submit' })); + }); + + // The newly added minLength rule should now fire + await waitFor(() => { + expect( + view.getByText('Must be at least 10 characters') + ).toBeInTheDocument(); + }); + }); + }); + + 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 95560991c7f..7bf0e6eb651 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; + customValidations?: RegisterOptions; } -export const useField = ({ name, disabled, loading }: useFieldProps) => { +export const useField = ({ + name, + disabled, + loading, + customValidations, +}: 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,19 @@ export const useField = ({ name, disabled, loading }: useFieldProps) => { loading, }); - const validation = + const formValidation = (validationRules && validationRules[name as keyof typeof validationRules]) ?? undefined; + const validation = useMemo( + () => + formValidation || customValidations + ? ({ ...formValidation, ...customValidations } as RegisterOptions) + : undefined, + [formValidation, customValidations] + ); + const ref = register(name, validation); return { @@ -344,7 +358,7 @@ type DebouncedFieldProps = Omit< GetInitialFormValueProps, 'setLocalValue' | 'defaultValue' > & - Pick & { + Pick & { type: T; shouldDirtyOnChange?: boolean; }; @@ -356,8 +370,14 @@ 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 : ''; 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..a0f1618b343 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..e7df153b6f3 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.