From 916de940bc2014bc4f81c1e3cec3f71a5d02fa7f Mon Sep 17 00:00:00 2001 From: Troy Chryssos Date: Fri, 23 Jan 2026 11:12:01 -0800 Subject: [PATCH 1/2] 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/2] 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);