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 4c4fe32659c..e7dd463347b 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) { @@ -75,13 +80,16 @@ export function ConnectedFormGroup({ } }, [customError, name, setError]); + const required = + Boolean(validation?.required) || Boolean(customValidations?.required); + const renderedLabel = ( {label} @@ -99,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 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..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 new file mode 100644 index 00000000000..26eb6f32065 --- /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 TestFormWithCustomValidations: 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..d4ad31eede2 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,16 @@ export const useField = ({ name, disabled, loading }: useFieldProps) => { loading, }); - const validation = + const formValidation = (validationRules && validationRules[name as keyof typeof validationRules]) ?? undefined; + const validation = + formValidation || customValidations + ? ({ ...formValidation, ...customValidations } as RegisterOptions) + : undefined; + const ref = register(name, validation); return {