Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
15 changes: 12 additions & 3 deletions packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,7 +43,10 @@ export interface ConnectedFormGroupProps<T extends ConnectedField>
/**
* 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<React.ComponentProps<T>, 'name' | 'disabled'> & FieldProps<T>;
field: Omit<React.ComponentProps<T>, 'name' | 'disabled'> &
FieldProps<T> & {
customValidations?: RegisterOptions;
};
}

export function ConnectedFormGroup<T extends ConnectedField>({
Expand All @@ -60,11 +64,12 @@ export function ConnectedFormGroup<T extends ConnectedField>({
isSoloField,
infotip,
}: ConnectedFormGroupProps<T>) {
const { component: Component, customValidations, ...rest } = field;
const { error, isFirstError, isDisabled, setError, validation } = useField({
name,
disabled,
customValidations,
});
const { component: Component, ...rest } = field;

useEffect(() => {
if (customError) {
Expand All @@ -75,13 +80,16 @@ export function ConnectedFormGroup<T extends ConnectedField>({
}
}, [customError, name, setError]);

const required =
Boolean(validation?.required) || Boolean(customValidations?.required);

const renderedLabel = (
<FormGroupLabel
disabled={isDisabled}
htmlFor={id || name}
infotip={infotip}
isSoloField={isSoloField}
required={!!validation?.required}
required={required}
size={labelSize}
>
{label}
Expand All @@ -99,6 +107,7 @@ export function ConnectedFormGroup<T extends ConnectedField>({
{...(rest as any)}
aria-describedby={errorId}
aria-invalid={showError}
customValidations={customValidations}
disabled={disabled}
name={name}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ export const ConnectedCheckbox: React.FC<ConnectedCheckboxProps> = ({
name,
onUpdate,
spacing,
customValidations,
}) => {
const { isDisabled, control, validation, isRequired } = useField({
name,
disabled,
customValidations,
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { ConnectedInputProps } from './types';
export const ConnectedInput: React.FC<ConnectedInputProps> = ({
disabled,
name,
customValidations,
...rest
}) => {
const { error, isDisabled, ref, isRequired } = useField({
name,
disabled,
customValidations,
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { ConnectedRadioProps } from './types';
export const ConnectedRadio: React.FC<ConnectedRadioProps> = ({
disabled,
name,
customValidations,
...rest
}) => {
const { error, isDisabled, ref } = useField({
name,
disabled,
customValidations,
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { ConnectedRadioGroupProps } from './types';
export const ConnectedRadioGroup: React.FC<ConnectedRadioGroupProps> = ({
name,
onChange,
customValidations,
...rest
}) => {
const { setValue, isRequired } = useField({ name });
const { setValue, isRequired } = useField({ name, customValidations });

return (
<RadioGroup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import { ConnectedRadioGroupInputProps } from './types';

export const ConnectedRadioGroupInput: React.FC<
ConnectedRadioGroupInputProps
> = ({ name, options, disabled, ...rest }) => {
> = ({ name, options, disabled, customValidations, ...rest }) => {
return (
<ConnectedRadioGroup name={name} {...rest}>
<ConnectedRadioGroup
name={name}
customValidations={customValidations}
{...rest}
>
{options.map((elem) => {
return (
<ConnectedRadio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { ConnectedSelectProps } from './types';
export const ConnectedSelect: React.FC<ConnectedSelectProps> = ({
disabled,
name,
customValidations,
...rest
}) => {
const { error, isDisabled, ref, isRequired } = useField({
name,
disabled,
customValidations,
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { ConnectedTextAreaProps } from './types';
export const ConnectedTextArea: React.FC<ConnectedTextAreaProps> = ({
disabled,
name,
customValidations,
...rest
}) => {
const { error, isDisabled, ref, isRequired } = useField({
name,
disabled,
customValidations,
});

return (
Expand Down
2 changes: 2 additions & 0 deletions packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { RegisterOptions } from 'react-hook-form';

import {
CheckboxLabelUnion,
Expand All @@ -15,6 +16,7 @@ export interface BaseConnectedFieldProps {
}
export interface ConnectedFieldProps extends BaseConnectedFieldProps {
name: string;
customValidations?: RegisterOptions;
}

export interface MinimalCheckboxProps
Expand Down
204 changes: 204 additions & 0 deletions packages/gamut/src/ConnectedForm/__tests__/useField.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ConnectedFormGroup
field={{
component: ConnectedInput,
customValidations: {
required: customRequiredMessage,
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: customErrorMessage,
},
},
}}
label="Email"
name={mockInputKey}
/>
<button type="submit">Submit</button>
</>
);
};

const TestFormWithBothValidations: React.FC = () => {
return (
<>
<ConnectedFormGroup
field={{
component: ConnectedInput,
customValidations: {
minLength: {
value: 5,
message: 'Email must be at least 5 characters',
},
},
}}
label="Email"
name={mockInputKey}
/>
<button type="submit">Submit</button>
</>
);
};

const renderView = setupRtl(ConnectedForm, {
defaultValues: {
[mockInputKey]: mockDefaultValue,
},
onSubmit: () => null,
children: <TestFormWithCustomValidations />,
});

const renderViewWithBothValidations = setupRtl(ConnectedForm, {
defaultValues: {
[mockInputKey]: mockDefaultValue,
},
validationRules: {
[mockInputKey]: {
required: 'This field is required from form level',
},
},
onSubmit: () => null,
children: <TestFormWithBothValidations />,
});

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');
});
});
Loading
Loading