From 53167ab30b0659727c3de9fbb8d31ace38aaedb2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 11 Aug 2025 14:03:32 -0400 Subject: [PATCH 01/25] wip --- .../ConnectedNestedCheckboxes.tsx | 264 ++++++++++++++++++ .../ConnectedForm/ConnectedInputs/index.ts | 1 + .../ConnectedForm/ConnectedInputs/types.tsx | 50 ++++ packages/gamut/src/ConnectedForm/types.ts | 2 + 4 files changed, 317 insertions(+) create mode 100644 packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx new file mode 100644 index 00000000000..837c741db26 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -0,0 +1,264 @@ +import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { Controller } from 'react-hook-form'; + +import { Checkbox } from '../..'; +import { useField } from '..'; +import { ConnectedNestedCheckboxesProps, NestedCheckboxOption } from './types'; + +/** + * ConnectedNestedCheckboxes - A form component that provides nested checkbox functionality + * with parent-child relationships and indeterminate states. + * + * @example + * ```tsx + * const options = [ + * { + * value: 'frontend', + * label: 'Frontend Technologies', + * children: [ + * { value: 'react', label: 'React' }, + * { value: 'vue', label: 'Vue.js' }, + * { value: 'angular', label: 'Angular' } + * ] + * }, + * { + * value: 'backend', + * label: 'Backend Technologies', + * children: [ + * { value: 'node', label: 'Node.js' }, + * { value: 'python', label: 'Python' }, + * { value: 'java', label: 'Java' } + * ] + * } + * ]; + * + * console.log(data.technologies)}> + * console.log('Selected:', selectedValues)} + * /> + * Submit + * + * ``` + * + * Features: + * - Hierarchical checkbox structure with unlimited nesting levels + * - Parent checkboxes show indeterminate state when some children are selected + * - Clicking a parent checkbox toggles all its children + * - Individual child checkboxes can be toggled independently + * - Returns array of selected leaf values (only actual selectable items, not parent categories) + * - Integrates with react-hook-form validation and form state + * - Supports disabled states at both parent and child levels + * - Proper accessibility attributes and keyboard navigation + */ + +interface FlatCheckboxState { + value: string; + checked: boolean; + indeterminate: boolean; + disabled: boolean; + label: React.ReactNode; + level: number; + parentValue?: string; + children: string[]; + checkboxProps?: any; +} + +export const ConnectedNestedCheckboxes: React.FC< + ConnectedNestedCheckboxesProps +> = ({ name, options, disabled, className, onUpdate }) => { + const { isDisabled, control, validation, isRequired } = useField({ + name, + disabled, + }); + + // Flatten the nested structure for easier state management + const flattenOptions = useCallback( + ( + opts: NestedCheckboxOption[], + level = 0, + parentValue?: string + ): FlatCheckboxState[] => { + const result: FlatCheckboxState[] = []; + + opts.forEach((option) => { + const children = option.children + ? option.children.map((child) => child.value) + : []; + + result.push({ + value: option.value, + checked: false, + indeterminate: false, + disabled: option.disabled || false, + label: option.label, + level, + parentValue, + children, + checkboxProps: option.checkboxProps, + }); + + if (option.children) { + result.push( + ...flattenOptions(option.children, level + 1, option.value) + ); + } + }); + + return result; + }, + [] + ); + + const flatOptions = useMemo( + () => flattenOptions(options), + [options, flattenOptions] + ); + + // Calculate checkbox states based on selected values + const calculateStates = useCallback( + (selectedValues: string[]) => { + const states = new Map(); + + // Initialize all states + flatOptions.forEach((option) => { + states.set(option.value, { + ...option, + checked: selectedValues.includes(option.value), + indeterminate: false, + }); + }); + + // Calculate parent states based on children + flatOptions.forEach((option) => { + if (option.children.length > 0) { + const checkedChildren = option.children.filter((childValue) => + selectedValues.includes(childValue) + ); + + const state = states.get(option.value)!; + if (checkedChildren.length === 0) { + state.checked = false; + state.indeterminate = false; + } else if (checkedChildren.length === option.children.length) { + state.checked = true; + state.indeterminate = false; + } else { + state.checked = false; + state.indeterminate = true; + } + } + }); + + return states; + }, + [flatOptions] + ); + + const handleCheckboxChange = useCallback( + ( + currentValue: string, + isChecked: boolean, + selectedValues: string[], + onChange: (values: string[]) => void + ) => { + const option = flatOptions.find((opt) => opt.value === currentValue); + if (!option) return; + + let newSelectedValues = [...selectedValues]; + + if (option.children.length > 0) { + // Parent checkbox - toggle all children + if (isChecked) { + // Add all children that aren't already selected + option.children.forEach((childValue) => { + if (!newSelectedValues.includes(childValue)) { + newSelectedValues.push(childValue); + } + }); + } else { + // Remove all children + newSelectedValues = newSelectedValues.filter( + (value) => !option.children.includes(value) + ); + } + } else { + // Child checkbox + if (isChecked) { + if (!newSelectedValues.includes(currentValue)) { + newSelectedValues.push(currentValue); + } + } else { + newSelectedValues = newSelectedValues.filter( + (value) => value !== currentValue + ); + } + } + + onChange(newSelectedValues); + onUpdate?.(newSelectedValues); + }, + [flatOptions, onUpdate] + ); + + const renderCheckbox = useCallback( + ( + option: FlatCheckboxState, + selectedValues: string[], + onChange: (values: string[]) => void + ) => { + const states = calculateStates(selectedValues); + const state = states.get(option.value)!; + const checkboxId = `${name}-${option.value}`; + + return ( +
+ { + handleCheckboxChange( + option.value, + event.target.checked, + selectedValues, + onChange + ); + }} + {...state.checkboxProps} + /> +
+ ); + }, + [calculateStates, name, isRequired, isDisabled, handleCheckboxChange] + ); + + return ( + ( +
+ {flatOptions.map((option) => + renderCheckbox(option, value || [], onChange) + )} +
+ )} + rules={validation} + /> + ); +}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/index.ts b/packages/gamut/src/ConnectedForm/ConnectedInputs/index.ts index e70600b912d..d60ebab0558 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/index.ts +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/index.ts @@ -1,5 +1,6 @@ export * from './ConnectedCheckbox'; export * from './ConnectedInput'; +export * from './ConnectedNestedCheckboxes'; export * from './ConnectedRadio'; export * from './ConnectedRadioGroup'; export * from './ConnectedRadioGroupInput'; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 11e4dd0f35e..9182783a00e 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -70,3 +70,53 @@ export interface ConnectedSelectProps export interface ConnectedTextAreaProps extends Omit, ConnectedFieldProps {} + +export interface NestedCheckboxOption { + /** + * Unique identifier for this checkbox option + */ + value: string; + /** + * Display label for the checkbox + */ + label: ReactNode; + /** + * Whether this option should be disabled + */ + disabled?: boolean; + /** + * Child options that are nested under this option + */ + children?: NestedCheckboxOption[]; + /** + * Additional props to pass to the individual Checkbox component + */ + checkboxProps?: Omit< + CheckboxProps, + 'checked' | 'onChange' | 'name' | 'htmlFor' | 'label' | 'disabled' + >; +} + +export interface ConnectedNestedCheckboxesProps { + /** + * Field name for form registration + */ + name: string; + /** + * Hierarchical structure of checkbox options + */ + options: NestedCheckboxOption[]; + /** + * Whether all checkboxes should be disabled + */ + disabled?: boolean; + /** + * CSS class name for the container + */ + className?: string; + /** + * Callback fired when the selection changes + * @param selectedValues Array of selected option values + */ + onUpdate?: (selectedValues: string[]) => void; +} diff --git a/packages/gamut/src/ConnectedForm/types.ts b/packages/gamut/src/ConnectedForm/types.ts index e0ead4f2faf..324697f9848 100644 --- a/packages/gamut/src/ConnectedForm/types.ts +++ b/packages/gamut/src/ConnectedForm/types.ts @@ -3,6 +3,7 @@ import { FieldValues, FormState } from 'react-hook-form'; import { ConnectedCheckbox, ConnectedInput, + ConnectedNestedCheckboxes, ConnectedRadioGroupInput, ConnectedSelect, ConnectedTextArea, @@ -12,6 +13,7 @@ import { BaseConnectedFieldProps } from './ConnectedInputs/types'; export type ConnectedField = | typeof ConnectedCheckbox | typeof ConnectedInput + | typeof ConnectedNestedCheckboxes | typeof ConnectedRadioGroupInput | typeof ConnectedSelect | typeof ConnectedTextArea; From cef1cbffeb3afc8c999470953fd39d4b7bc442b9 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 5 Sep 2025 10:07:43 -0400 Subject: [PATCH 02/25] working --- .../ConnectedNestedCheckboxes.tsx | 102 +++++++++++------- .../ConnectedForm/ConnectedInputs/types.tsx | 47 ++------ .../ConnectedForm/ConnectedForm.stories.tsx | 32 ++++++ 3 files changed, 102 insertions(+), 79 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index 837c741db26..b6388200638 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useCallback, useMemo } from 'react'; import { Controller } from 'react-hook-form'; -import { Checkbox } from '../..'; +import { Box, Checkbox } from '../..'; import { useField } from '..'; import { ConnectedNestedCheckboxesProps, NestedCheckboxOption } from './types'; @@ -68,7 +68,7 @@ interface FlatCheckboxState { export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps -> = ({ name, options, disabled, className, onUpdate }) => { +> = ({ name, options, disabled, onUpdate }) => { const { isDisabled, control, validation, isRequired } = useField({ name, disabled, @@ -84,12 +84,14 @@ export const ConnectedNestedCheckboxes: React.FC< const result: FlatCheckboxState[] = []; opts.forEach((option) => { + // Ensure value is a string + const optionValue = String(option.value || ''); const children = option.children - ? option.children.map((child) => child.value) + ? option.children.map((child) => String(child.value || '')) : []; result.push({ - value: option.value, + value: optionValue, checked: false, indeterminate: false, disabled: option.disabled || false, @@ -97,12 +99,12 @@ export const ConnectedNestedCheckboxes: React.FC< level, parentValue, children, - checkboxProps: option.checkboxProps, + checkboxProps: {}, }); if (option.children) { result.push( - ...flattenOptions(option.children, level + 1, option.value) + ...flattenOptions(option.children, level + 1, optionValue) ); } }); @@ -117,6 +119,27 @@ export const ConnectedNestedCheckboxes: React.FC< [options, flattenOptions] ); + // Helper function to get all descendants of a given option + const getAllDescendants = useCallback( + (parentValue: string): string[] => { + const descendants: string[] = []; + + const collectDescendants = (currentParentValue: string) => { + flatOptions.forEach((option) => { + if (option.parentValue === currentParentValue) { + descendants.push(option.value); + // Recursively collect descendants of this option + collectDescendants(option.value); + } + }); + }; + + collectDescendants(parentValue); + return descendants; + }, + [flatOptions] + ); + // Calculate checkbox states based on selected values const calculateStates = useCallback( (selectedValues: string[]) => { @@ -131,18 +154,19 @@ export const ConnectedNestedCheckboxes: React.FC< }); }); - // Calculate parent states based on children + // Calculate parent states based on all descendants (infinite levels) flatOptions.forEach((option) => { if (option.children.length > 0) { - const checkedChildren = option.children.filter((childValue) => - selectedValues.includes(childValue) + const allDescendants = getAllDescendants(option.value); + const checkedDescendants = allDescendants.filter((descendantValue) => + selectedValues.includes(descendantValue) ); const state = states.get(option.value)!; - if (checkedChildren.length === 0) { + if (checkedDescendants.length === 0) { state.checked = false; state.indeterminate = false; - } else if (checkedChildren.length === option.children.length) { + } else if (checkedDescendants.length === allDescendants.length) { state.checked = true; state.indeterminate = false; } else { @@ -154,7 +178,7 @@ export const ConnectedNestedCheckboxes: React.FC< return states; }, - [flatOptions] + [flatOptions, getAllDescendants] ); const handleCheckboxChange = useCallback( @@ -170,37 +194,39 @@ export const ConnectedNestedCheckboxes: React.FC< let newSelectedValues = [...selectedValues]; if (option.children.length > 0) { - // Parent checkbox - toggle all children + // Parent checkbox - toggle all descendants (infinite levels) + const allDescendants = getAllDescendants(currentValue); + if (isChecked) { - // Add all children that aren't already selected - option.children.forEach((childValue) => { - if (!newSelectedValues.includes(childValue)) { - newSelectedValues.push(childValue); + // Add all descendants that aren't already selected + allDescendants.forEach((descendantValue) => { + if (!newSelectedValues.includes(descendantValue)) { + newSelectedValues.push(descendantValue); } }); } else { - // Remove all children + // Remove all descendants newSelectedValues = newSelectedValues.filter( - (value) => !option.children.includes(value) + (value) => !allDescendants.includes(value) ); } - } else { - // Child checkbox - if (isChecked) { - if (!newSelectedValues.includes(currentValue)) { - newSelectedValues.push(currentValue); - } - } else { - newSelectedValues = newSelectedValues.filter( - (value) => value !== currentValue - ); + } + + // Handle the current checkbox itself (for leaf nodes or when toggling individual items) + if (isChecked) { + if (!newSelectedValues.includes(currentValue)) { + newSelectedValues.push(currentValue); } + } else { + newSelectedValues = newSelectedValues.filter( + (value) => value !== currentValue + ); } onChange(newSelectedValues); onUpdate?.(newSelectedValues); }, - [flatOptions, onUpdate] + [flatOptions, onUpdate, getAllDescendants] ); const renderCheckbox = useCallback( @@ -214,20 +240,14 @@ export const ConnectedNestedCheckboxes: React.FC< const checkboxId = `${name}-${option.value}`; return ( -
+ { @@ -240,7 +260,7 @@ export const ConnectedNestedCheckboxes: React.FC< }} {...state.checkboxProps} /> -
+ ); }, [calculateStates, name, isRequired, isDisabled, handleCheckboxChange] @@ -252,11 +272,11 @@ export const ConnectedNestedCheckboxes: React.FC< defaultValue={[]} name={name} render={({ field: { value, onChange } }) => ( -
+ {flatOptions.map((option) => renderCheckbox(option, value || [], onChange) )} -
+ )} rules={validation} /> diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 9182783a00e..f6e4b82725f 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -14,6 +14,10 @@ export interface BaseConnectedFieldProps { onUpdate?: (value: boolean) => void; } +export interface BaseConnectedNestedCheckboxFieldProps { + onUpdate?: (values: string[]) => void; +} + export interface ConnectedFieldProps extends BaseConnectedFieldProps { name: string; } @@ -71,52 +75,19 @@ export interface ConnectedTextAreaProps extends Omit, ConnectedFieldProps {} -export interface NestedCheckboxOption { - /** - * Unique identifier for this checkbox option - */ - value: string; - /** - * Display label for the checkbox - */ - label: ReactNode; - /** - * Whether this option should be disabled - */ - disabled?: boolean; - /** - * Child options that are nested under this option - */ +export type NestedCheckboxOption = ConnectedCheckboxProps & { children?: NestedCheckboxOption[]; - /** - * Additional props to pass to the individual Checkbox component - */ - checkboxProps?: Omit< - CheckboxProps, - 'checked' | 'onChange' | 'name' | 'htmlFor' | 'label' | 'disabled' - >; -} +}; -export interface ConnectedNestedCheckboxesProps { - /** - * Field name for form registration - */ +export interface ConnectedNestedCheckboxesProps + extends BaseConnectedNestedCheckboxFieldProps { name: string; /** * Hierarchical structure of checkbox options */ options: NestedCheckboxOption[]; /** - * Whether all checkboxes should be disabled + * Disable all checkboxes */ disabled?: boolean; - /** - * CSS class name for the container - */ - className?: string; - /** - * Callback fired when the selection changes - * @param selectedValues Array of selected option values - */ - onUpdate?: (selectedValues: string[]) => void; } 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 6634c022899..2d013d51ae2 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -4,6 +4,7 @@ import { ConnectedFormGroupProps, ConnectedFormProps, ConnectedInput, + ConnectedNestedCheckboxes, ConnectedRadioGroupInput, ConnectedSelect, ConnectedTextArea, @@ -238,6 +239,37 @@ const ConnectedFormPlayground: React.FC = ({ name="textAreaField" {...connectedFormGroup} /> + console.log('Selected:', selectedValues)} + /> ); }; From 3a53551f0209285526f2b4f51c80565baded6f00 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 8 Sep 2025 13:55:33 -0400 Subject: [PATCH 03/25] update types --- .../ConnectedNestedCheckboxes.tsx | 125 ++++++------------ .../ConnectedForm/ConnectedInputs/types.tsx | 16 +-- .../ConnectedForm/ConnectedForm.stories.tsx | 1 + 3 files changed, 44 insertions(+), 98 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index b6388200638..a4f52a24ba2 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -4,67 +4,17 @@ import { Controller } from 'react-hook-form'; import { Box, Checkbox } from '../..'; import { useField } from '..'; -import { ConnectedNestedCheckboxesProps, NestedCheckboxOption } from './types'; +import { + ConnectedCheckboxProps, + ConnectedNestedCheckboxesProps, + NestedCheckboxOption, +} from './types'; -/** - * ConnectedNestedCheckboxes - A form component that provides nested checkbox functionality - * with parent-child relationships and indeterminate states. - * - * @example - * ```tsx - * const options = [ - * { - * value: 'frontend', - * label: 'Frontend Technologies', - * children: [ - * { value: 'react', label: 'React' }, - * { value: 'vue', label: 'Vue.js' }, - * { value: 'angular', label: 'Angular' } - * ] - * }, - * { - * value: 'backend', - * label: 'Backend Technologies', - * children: [ - * { value: 'node', label: 'Node.js' }, - * { value: 'python', label: 'Python' }, - * { value: 'java', label: 'Java' } - * ] - * } - * ]; - * - * console.log(data.technologies)}> - * console.log('Selected:', selectedValues)} - * /> - * Submit - * - * ``` - * - * Features: - * - Hierarchical checkbox structure with unlimited nesting levels - * - Parent checkboxes show indeterminate state when some children are selected - * - Clicking a parent checkbox toggles all its children - * - Individual child checkboxes can be toggled independently - * - Returns array of selected leaf values (only actual selectable items, not parent categories) - * - Integrates with react-hook-form validation and form state - * - Supports disabled states at both parent and child levels - * - Proper accessibility attributes and keyboard navigation - */ - -interface FlatCheckboxState { - value: string; - checked: boolean; - indeterminate: boolean; - disabled: boolean; - label: React.ReactNode; +type FlatCheckboxState = ConnectedCheckboxProps & { level: number; parentValue?: string; children: string[]; - checkboxProps?: any; -} +}; export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps @@ -76,30 +26,22 @@ export const ConnectedNestedCheckboxes: React.FC< // Flatten the nested structure for easier state management const flattenOptions = useCallback( - ( - opts: NestedCheckboxOption[], - level = 0, - parentValue?: string - ): FlatCheckboxState[] => { + (opts: NestedCheckboxOption[], level = 0, parentValue?: string) => { const result: FlatCheckboxState[] = []; opts.forEach((option) => { // Ensure value is a string - const optionValue = String(option.value || ''); + const optionValue = String(option.value); const children = option.children - ? option.children.map((child) => String(child.value || '')) + ? option.children.map((child) => String(child.value)) : []; result.push({ + ...option, value: optionValue, - checked: false, - indeterminate: false, - disabled: option.disabled || false, - label: option.label, level, parentValue, children, - checkboxProps: {}, }); if (option.children) { @@ -121,15 +63,15 @@ export const ConnectedNestedCheckboxes: React.FC< // Helper function to get all descendants of a given option const getAllDescendants = useCallback( - (parentValue: string): string[] => { + (parentValue: string) => { const descendants: string[] = []; const collectDescendants = (currentParentValue: string) => { flatOptions.forEach((option) => { if (option.parentValue === currentParentValue) { - descendants.push(option.value); + descendants.push(String(option.value)); // Recursively collect descendants of this option - collectDescendants(option.value); + collectDescendants(String(option.value)); } }); }; @@ -147,28 +89,26 @@ export const ConnectedNestedCheckboxes: React.FC< // Initialize all states flatOptions.forEach((option) => { - states.set(option.value, { + states.set(String(option.value), { ...option, - checked: selectedValues.includes(option.value), - indeterminate: false, + checked: selectedValues.includes(String(option.value)), }); }); // Calculate parent states based on all descendants (infinite levels) flatOptions.forEach((option) => { if (option.children.length > 0) { - const allDescendants = getAllDescendants(option.value); + const allDescendants = getAllDescendants(String(option.value)); const checkedDescendants = allDescendants.filter((descendantValue) => selectedValues.includes(descendantValue) ); - const state = states.get(option.value)!; + const state = states.get(String(option.value))!; if (checkedDescendants.length === 0) { state.checked = false; state.indeterminate = false; } else if (checkedDescendants.length === allDescendants.length) { state.checked = true; - state.indeterminate = false; } else { state.checked = false; state.indeterminate = true; @@ -236,29 +176,46 @@ export const ConnectedNestedCheckboxes: React.FC< onChange: (values: string[]) => void ) => { const states = calculateStates(selectedValues); - const state = states.get(option.value)!; + const state = states.get(String(option.value))!; const checkboxId = `${name}-${option.value}`; + let checkedProps = {}; + if (state.checked) { + checkedProps = { + checked: true, + }; + } else if (state.indeterminate) { + checkedProps = { + indeterminate: true, + checked: false, + }; + } + return ( - + { handleCheckboxChange( - option.value, + String(option.value), event.target.checked, selectedValues, onChange ); }} - {...state.checkboxProps} + {...checkedProps} /> ); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index f6e4b82725f..33fbfb044ce 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -13,11 +13,6 @@ import { export interface BaseConnectedFieldProps { onUpdate?: (value: boolean) => void; } - -export interface BaseConnectedNestedCheckboxFieldProps { - onUpdate?: (values: string[]) => void; -} - export interface ConnectedFieldProps extends BaseConnectedFieldProps { name: string; } @@ -80,14 +75,7 @@ export type NestedCheckboxOption = ConnectedCheckboxProps & { }; export interface ConnectedNestedCheckboxesProps - extends BaseConnectedNestedCheckboxFieldProps { - name: string; - /** - * Hierarchical structure of checkbox options - */ + extends Pick { options: NestedCheckboxOption[]; - /** - * Disable all checkboxes - */ - disabled?: boolean; + onUpdate?: (values: string[]) => void; } 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 2d013d51ae2..16b9568f747 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -153,6 +153,7 @@ const ConnectedFormPlayground: React.FC = ({ justifyContent="space-between" minHeight="50rem" onSubmit={(values) => { + console.log('values', values); action('Form Submitted')(values); }} {...connectedFormProps} From b1d05a8e289b9d95e826f27562f7dae646143e5a Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 9 Sep 2025 13:37:13 -0400 Subject: [PATCH 04/25] add aria-checked --- .../ConnectedInputs/ConnectedNestedCheckboxes.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index a4f52a24ba2..1eb1ebc6005 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -183,16 +183,23 @@ export const ConnectedNestedCheckboxes: React.FC< if (state.checked) { checkedProps = { checked: true, + 'aria-checked': true, }; } else if (state.indeterminate) { checkedProps = { indeterminate: true, checked: false, + 'aria-checked': 'mixed', + }; + } else { + checkedProps = { + checked: false, + 'aria-checked': false, }; } return ( - + Date: Tue, 9 Sep 2025 13:37:25 -0400 Subject: [PATCH 05/25] update stories --- .../ConnectedForm/ConnectedForm.stories.tsx | 66 ++++++++++--------- .../ConnectedFormInputs.mdx | 44 ++++++++++++- 2 files changed, 79 insertions(+), 31 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 16b9568f747..be03774bd3f 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -128,6 +128,7 @@ const ConnectedFormPlayground: React.FC = ({ inputField: '', radioGroupField: undefined, textAreaField: '', + nestedCheckboxesField: [], }, validationRules: { checkboxField: { required: 'you need to check this.' }, @@ -240,36 +241,41 @@ const ConnectedFormPlayground: React.FC = ({ name="textAreaField" {...connectedFormGroup} /> - console.log('Selected:', selectedValues)} + + console.log('Selected:', selectedValues), + }} + label="nested checkboxes field" + name="nestedCheckboxesField" /> ); diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index e6a1b610adc..2236e3d417e 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -28,12 +28,54 @@ We have a selection of `ConnectedInput` components that are visually identical t ### ConnectedRadioGroupInput -`ConnectedRadioGroupInput` is the exception to the rule, and has some props that differ, particularly `options` — which takes an array of `ConnectedBaseRadioInputProps` components. +`ConnectedRadioGroupInput` is an exception to the rule, and has some props that differ, particularly `options` — which takes an array of `ConnectedBaseRadioInputProps` components. For further styling configurations, check out RadioGroup. `ConnectedRadioGroup` and `ConnectedRadio` should rarely, if ever, be used outside of `ConnectedRadioGroupInput`. +### ConnectedNestedCheckboxes + +`ConnectedNestedCheckboxes` is a component that allows you to create a nested checkbox group. It is a wrapper around the `ConnectedCheckbox` component and takes in an array `NestedCheckboxOption`s. Infinite levels of nesting are supported. Clicking a parent checkbox toggles all its children accordingly. Individual child checkboxes can be toggled independently. Checkbox states are `checked` if all children are checked, `indeterminate` if some children are checked, or `unchecked` if no children are checked. + +```tsx + +``` + + ## Usage The components are engineered to be passed into the component prop of ConnectedFormGroup, like so: From ec5e50308e67006970849f5aa9c2c7e7c884a497 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 15 Sep 2025 13:43:31 -0400 Subject: [PATCH 06/25] fix connectednestedcheckbox --- .../ConnectedNestedCheckboxes.tsx | 17 ++++++++++++----- .../src/ConnectedForm/ConnectedInputs/types.tsx | 9 +++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index 1eb1ebc6005..e8dee522638 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -18,7 +18,7 @@ type FlatCheckboxState = ConnectedCheckboxProps & { export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps -> = ({ name, options, disabled, onUpdate }) => { +> = ({ name, options, disabled, onUpdate, spacing }) => { const { isDisabled, control, validation, isRequired } = useField({ name, disabled, @@ -38,6 +38,7 @@ export const ConnectedNestedCheckboxes: React.FC< result.push({ ...option, + spacing, value: optionValue, level, parentValue, @@ -53,7 +54,7 @@ export const ConnectedNestedCheckboxes: React.FC< return result; }, - [] + [spacing] ); const flatOptions = useMemo( @@ -173,7 +174,9 @@ export const ConnectedNestedCheckboxes: React.FC< ( option: FlatCheckboxState, selectedValues: string[], - onChange: (values: string[]) => void + onChange: (values: string[]) => void, + onBlur: () => void, + ref: React.RefCallback ) => { const states = calculateStates(selectedValues); const state = states.get(String(option.value))!; @@ -213,7 +216,10 @@ export const ConnectedNestedCheckboxes: React.FC< htmlFor={checkboxId} id={checkboxId} label={state.label} + multiline={state.multiline} name={`${name}-${option.value}`} + spacing={state.spacing} + onBlur={onBlur} onChange={(event) => { handleCheckboxChange( String(option.value), @@ -223,6 +229,7 @@ export const ConnectedNestedCheckboxes: React.FC< ); }} {...checkedProps} + {...ref} /> ); @@ -235,10 +242,10 @@ export const ConnectedNestedCheckboxes: React.FC< control={control} defaultValue={[]} name={name} - render={({ field: { value, onChange } }) => ( + render={({ field: { value, onChange, onBlur, ref } }) => ( {flatOptions.map((option) => - renderCheckbox(option, value || [], onChange) + renderCheckbox(option, value || [], onChange, onBlur, ref) )} )} diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 33fbfb044ce..ad01bc9b7a1 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -70,12 +70,13 @@ export interface ConnectedTextAreaProps extends Omit, ConnectedFieldProps {} -export type NestedCheckboxOption = ConnectedCheckboxProps & { - children?: NestedCheckboxOption[]; -}; +export type NestedCheckboxOption = Omit & + CheckboxLabelUnion & { + children?: NestedCheckboxOption[]; + }; export interface ConnectedNestedCheckboxesProps - extends Pick { + extends Pick { options: NestedCheckboxOption[]; onUpdate?: (values: string[]) => void; } From 9562d554904302d7cb44d252b858e8ccb3efe3f3 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 15 Sep 2025 15:45:18 -0400 Subject: [PATCH 07/25] dont use children as prop name --- .../ConnectedNestedCheckboxes.tsx | 46 +-- .../ConnectedForm/ConnectedInputs/types.tsx | 25 +- .../GridFormNestedCheckboxInput/index.tsx | 267 ++++++++++++++++++ .../src/GridForm/GridFormInputGroup/index.tsx | 10 + packages/gamut/src/GridForm/types.ts | 20 +- .../ConnectedForm/ConnectedForm.stories.tsx | 6 +- .../Organisms/GridForm/GridForm.stories.tsx | 34 +++ 7 files changed, 374 insertions(+), 34 deletions(-) create mode 100644 packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index e8dee522638..8855ea4aeb2 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -2,19 +2,20 @@ import * as React from 'react'; import { useCallback, useMemo } from 'react'; import { Controller } from 'react-hook-form'; -import { Box, Checkbox } from '../..'; +import { Box, Checkbox, CheckboxLabelUnion } from '../..'; import { useField } from '..'; import { - ConnectedCheckboxProps, ConnectedNestedCheckboxesProps, - NestedCheckboxOption, + MinimalCheckboxProps, + NestedConnectedCheckboxOption, } from './types'; -type FlatCheckboxState = ConnectedCheckboxProps & { - level: number; - parentValue?: string; - children: string[]; -}; +type FlatCheckboxState = MinimalCheckboxProps & + CheckboxLabelUnion & { + level: number; + parentValue?: string; + options: string[]; + }; export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps @@ -26,14 +27,18 @@ export const ConnectedNestedCheckboxes: React.FC< // Flatten the nested structure for easier state management const flattenOptions = useCallback( - (opts: NestedCheckboxOption[], level = 0, parentValue?: string) => { + ( + opts: NestedConnectedCheckboxOption[], + level = 0, + parentValue?: string + ) => { const result: FlatCheckboxState[] = []; opts.forEach((option) => { // Ensure value is a string const optionValue = String(option.value); - const children = option.children - ? option.children.map((child) => String(child.value)) + const options = option.options + ? option.options.map((child) => String(child.value)) : []; result.push({ @@ -42,12 +47,12 @@ export const ConnectedNestedCheckboxes: React.FC< value: optionValue, level, parentValue, - children, + options, }); - if (option.children) { + if (option.options) { result.push( - ...flattenOptions(option.children, level + 1, optionValue) + ...flattenOptions(option.options, level + 1, optionValue) ); } }); @@ -98,7 +103,7 @@ export const ConnectedNestedCheckboxes: React.FC< // Calculate parent states based on all descendants (infinite levels) flatOptions.forEach((option) => { - if (option.children.length > 0) { + if (option.options.length > 0) { const allDescendants = getAllDescendants(String(option.value)); const checkedDescendants = allDescendants.filter((descendantValue) => selectedValues.includes(descendantValue) @@ -134,7 +139,7 @@ export const ConnectedNestedCheckboxes: React.FC< let newSelectedValues = [...selectedValues]; - if (option.children.length > 0) { + if (option.options.length > 0) { // Parent checkbox - toggle all descendants (infinite levels) const allDescendants = getAllDescendants(currentValue); @@ -202,7 +207,12 @@ export const ConnectedNestedCheckboxes: React.FC< } return ( - + ( - + {flatOptions.map((option) => renderCheckbox(option, value || [], onChange, onBlur, ref) )} diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index ad01bc9b7a1..11fd80a251d 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -16,16 +16,14 @@ export interface BaseConnectedFieldProps { export interface ConnectedFieldProps extends BaseConnectedFieldProps { name: string; } -export interface BaseConnectedCheckboxProps + +export interface MinimalCheckboxProps extends Omit< - CheckboxProps, - | 'defaultValue' - | 'name' - | 'htmlFor' - | 'validation' - | 'label' - | 'aria-label' - >, + CheckboxProps, + 'defaultValue' | 'name' | 'htmlFor' | 'validation' | 'label' | 'aria-label' + > {} +export interface BaseConnectedCheckboxProps + extends MinimalCheckboxProps, ConnectedFieldProps {} export type ConnectedCheckboxProps = BaseConnectedCheckboxProps & @@ -70,13 +68,16 @@ export interface ConnectedTextAreaProps extends Omit, ConnectedFieldProps {} -export type NestedCheckboxOption = Omit & +export type NestedConnectedCheckboxOption = Omit< + MinimalCheckboxProps, + 'spacing' +> & CheckboxLabelUnion & { - children?: NestedCheckboxOption[]; + options?: NestedConnectedCheckboxOption[]; }; export interface ConnectedNestedCheckboxesProps extends Pick { - options: NestedCheckboxOption[]; + options: NestedConnectedCheckboxOption[]; onUpdate?: (values: string[]) => void; } diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx new file mode 100644 index 00000000000..c24582e2bfb --- /dev/null +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -0,0 +1,267 @@ +import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { Controller } from 'react-hook-form'; + +import { Box, Checkbox, CheckboxPaddingProps } from '../../..'; +import { + BaseFormInputProps, + GridFormNestedCheckboxField, + NestedGridFormCheckboxOption, +} from '../../types'; + +export interface GridFormNestedCheckboxInputProps extends BaseFormInputProps { + field: GridFormNestedCheckboxField; +} + +type FlatCheckboxState = Omit & + CheckboxPaddingProps & { + level: number; + parentValue?: string; + options: string[]; + }; + +export const GridFormNestedCheckboxInput: React.FC< + GridFormNestedCheckboxInputProps +> = ({ field, required, disabled, error }) => { + const isDisabled = disabled || field.disabled; + + // Flatten the nested structure for easier state management + const flattenOptions = useCallback( + (opts: NestedGridFormCheckboxOption[], level = 0, parentValue?: string) => { + const result: FlatCheckboxState[] = []; + + opts.forEach((option) => { + // Ensure value is a string + const optionValue = String(option.value); + const options = option.options + ? option.options.map((child) => String(child.value)) + : []; + + result.push({ + ...option, + spacing: field.spacing, + value: optionValue, + level, + parentValue, + options, + }); + + if (option.options) { + result.push( + ...flattenOptions(option.options, level + 1, optionValue) + ); + } + }); + + return result; + }, + [field.spacing] + ); + + const flatOptions = useMemo( + () => flattenOptions(field.options), + [field.options, flattenOptions] + ); + + // Helper function to get all descendants of a given option + const getAllDescendants = useCallback( + (parentValue: string) => { + const descendants: string[] = []; + + const collectDescendants = (currentParentValue: string) => { + flatOptions.forEach((option) => { + if (option.parentValue === currentParentValue) { + descendants.push(String(option.value)); + // Recursively collect descendants of this option + collectDescendants(String(option.value)); + } + }); + }; + + collectDescendants(parentValue); + return descendants; + }, + [flatOptions] + ); + + // Calculate checkbox states based on selected values + const calculateStates = useCallback( + (selectedValues: string[]) => { + const states = new Map(); + + // Initialize all states + flatOptions.forEach((option) => { + states.set(String(option.value), { + ...option, + checked: selectedValues.includes(String(option.value)), + }); + }); + + // Calculate parent states based on all descendants (infinite levels) + flatOptions.forEach((option) => { + if (option.options.length > 0) { + const allDescendants = getAllDescendants(String(option.value)); + const checkedDescendants = allDescendants.filter((descendantValue) => + selectedValues.includes(descendantValue) + ); + + const state = states.get(String(option.value))!; + if (checkedDescendants.length === 0) { + state.checked = false; + state.indeterminate = false; + } else if (checkedDescendants.length === allDescendants.length) { + state.checked = true; + } else { + state.checked = false; + state.indeterminate = true; + } + } + }); + + return states; + }, + [flatOptions, getAllDescendants] + ); + + const handleCheckboxChange = useCallback( + ( + currentValue: string, + isChecked: boolean, + selectedValues: string[], + onChange: (values: string[]) => void + ) => { + const option = flatOptions.find((opt) => opt.value === currentValue); + if (!option) return; + + let newSelectedValues = [...selectedValues]; + + if (option.options.length > 0) { + // Parent checkbox - toggle all descendants (infinite levels) + const allDescendants = getAllDescendants(currentValue); + + if (isChecked) { + // Add all descendants that aren't already selected + allDescendants.forEach((descendantValue) => { + if (!newSelectedValues.includes(descendantValue)) { + newSelectedValues.push(descendantValue); + } + }); + } else { + // Remove all descendants + newSelectedValues = newSelectedValues.filter( + (value) => !allDescendants.includes(value) + ); + } + } + + // Handle the current checkbox itself (for leaf nodes or when toggling individual items) + if (isChecked) { + if (!newSelectedValues.includes(currentValue)) { + newSelectedValues.push(currentValue); + } + } else { + newSelectedValues = newSelectedValues.filter( + (value) => value !== currentValue + ); + } + + onChange(newSelectedValues); + field.onUpdate?.(newSelectedValues); + }, + [flatOptions, field, getAllDescendants] + ); + + const renderCheckbox = useCallback( + ( + option: FlatCheckboxState, + selectedValues: string[], + onChange: (values: string[]) => void, + onBlur: () => void + ) => { + const states = calculateStates(selectedValues); + const state = states.get(String(option.value))!; + const checkboxId = field.id || `${field.name}-${option.value}`; + + let checkedProps = {}; + if (state.checked) { + checkedProps = { + checked: true, + 'aria-checked': true, + }; + } else if (state.indeterminate) { + checkedProps = { + indeterminate: true, + checked: false, + 'aria-checked': 'mixed', + }; + } else { + checkedProps = { + checked: false, + 'aria-checked': false, + }; + } + + return ( + + { + handleCheckboxChange( + String(option.value), + event.target.checked, + selectedValues, + onChange + ); + }} + {...checkedProps} + /> + + ); + }, + [ + calculateStates, + field.name, + field.id, + required, + isDisabled, + handleCheckboxChange, + error, + ] + ); + + return ( + ( + + {flatOptions.map((option) => + renderCheckbox(option, value || [], onChange, onBlur) + )} + + )} + rules={field.validation} + /> + ); +}; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx index 7fa3b5a9f00..6564f7248e3 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx @@ -17,6 +17,7 @@ import { GridFormCheckboxInput } from './GridFormCheckboxInput'; import { GridFormCustomInput } from './GridFormCustomInput'; import { GridFormFileInput } from './GridFormFileInput'; import { GridFormHiddenInput } from './GridFormHiddenInput'; +import { GridFormNestedCheckboxInput } from './GridFormNestedCheckboxInput'; import { GridFormRadioGroupInput } from './GridFormRadioGroupInput'; import { GridFormSelectInput } from './GridFormSelectInput'; import { GridFormSweetContainerInput } from './GridFormSweetContainerInput'; @@ -58,6 +59,15 @@ export const GridFormInputGroup: React.FC = ({ case 'checkbox': return ; + case 'nested-checkboxes': + return ( + + ); + case 'custom': case 'custom-group': return ( diff --git a/packages/gamut/src/GridForm/types.ts b/packages/gamut/src/GridForm/types.ts index 27df2d12707..4ac4a39b498 100644 --- a/packages/gamut/src/GridForm/types.ts +++ b/packages/gamut/src/GridForm/types.ts @@ -2,7 +2,8 @@ import { ReactNode } from 'react'; import { RegisterOptions, UseFormReturn } from 'react-hook-form'; import { BoxProps } from '../Box'; -import { TextAreaProps } from '../Form'; +import { MinimalCheckboxProps } from '../ConnectedForm'; +import { CheckboxLabelUnion, TextAreaProps } from '../Form'; import { CheckboxPaddingProps } from '../Form/types'; import { ColumnProps } from '../Layout'; import { InfoTipProps } from '../Tip/InfoTip'; @@ -48,6 +49,22 @@ export type GridFormCheckboxField = BaseFormField & type: 'checkbox'; }; +export type NestedGridFormCheckboxOption = Omit< + MinimalCheckboxProps, + 'spacing' +> & + CheckboxLabelUnion & { + options?: NestedGridFormCheckboxOption[]; + }; + +export type GridFormNestedCheckboxField = BaseFormField & + CheckboxPaddingProps & { + label?: React.ReactNode; + options: NestedGridFormCheckboxOption[]; + validation?: RegisterOptions; + type: 'nested-checkboxes'; + }; + export type GridFormCustomFieldProps = { className?: string; error?: string; @@ -140,6 +157,7 @@ export type GridFormField = | GridFormCheckboxField | GridFormCustomField | GridFormCustomGroupField + | GridFormNestedCheckboxField | GridFormRadioGroupField | GridFormTextField | GridFormSelectField 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 be03774bd3f..1e66fd9dc90 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -248,12 +248,12 @@ const ConnectedFormPlayground: React.FC = ({ { value: 'frontend', label: 'Frontend Technologies', - children: [ + options: [ { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue.js', - children: [ + options: [ { value: 'test', label: 'Test' }, { value: 'test2', label: 'Test2' }, ], @@ -264,7 +264,7 @@ const ConnectedFormPlayground: React.FC = ({ { value: 'backend', label: 'Backend Technologies', - children: [ + options: [ { value: 'node', label: 'Node.js' }, { value: 'python', label: 'Python' }, { value: 'java', label: 'Java' }, diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index 07ef3698255..9e1c2c0a5cb 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -135,6 +135,40 @@ const DefaultExample = (args: DefaultExampleProps) => { }, size: 4, }, + { + label: 'Nested checkboxes', + name: 'nested-checkboxes', + type: 'nested-checkboxes', + spacing: 'tight', + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { + value: 'vue', + label: 'Vue.js', + options: [ + { value: 'test', label: 'Test' }, + { value: 'test2', label: 'Test2' }, + ], + }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], + size: 4, + }, ]} submit={{ contents: 'Submit Me!?', From f8dbe3003424449725abd77bdf4469a12ac616dc Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 15 Sep 2025 15:50:13 -0400 Subject: [PATCH 08/25] add back in nested examples --- .../ConnectedForm/ConnectedForm.stories.tsx | 37 +++++++++++++++++++ .../Organisms/GridForm/GridForm.stories.tsx | 33 +++++++++++++++++ 2 files changed, 70 insertions(+) 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 89199372428..34943802627 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -32,6 +32,7 @@ export const Default = () => { inputField: '', radioGroupField: undefined, textAreaField: '', + nestedCheckboxesField: [], }, validationRules: { checkboxField: { required: 'you need to check this.' }, @@ -137,6 +138,42 @@ export const Default = () => { label="text area field" name="textAreaField" /> + + console.log('Selected:', selectedValues), + }} + label="nested checkboxes field" + name="nestedCheckboxesField" + /> ); }; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index 67108aa8e3d..8e9d50580e8 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -116,6 +116,39 @@ const meta: Meta = { }, size: 4, }, + { + label: 'Nested checkboxes', + name: 'nested-checkboxes', + type: 'nested-checkboxes', + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { + value: 'vue', + label: 'Vue.js', + options: [ + { value: 'test', label: 'Test' }, + { value: 'test2', label: 'Test2' }, + ], + }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], + size: 12, + }, ], submit: { contents: 'Submit Me!?', From 309c8a391cd44d4671f4de8f35c52ff0eb216bc3 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 15 Sep 2025 15:59:34 -0400 Subject: [PATCH 09/25] fix errors --- packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap | 1 + .../ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx | 1 + .../ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index fea4d396478..0795c55b29a 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -21,6 +21,7 @@ exports[`Gamut Exported Keys 1`] = ` "ConnectedForm", "ConnectedFormGroup", "ConnectedInput", + "ConnectedNestedCheckboxes", "ConnectedRadio", "ConnectedRadioGroup", "ConnectedRadioGroupInput", 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 34943802627..bf2fe36c399 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -169,6 +169,7 @@ export const Default = () => { }, ], onUpdate: (selectedValues) => + // eslint-disable-next-line no-console console.log('Selected:', selectedValues), }} label="nested checkboxes field" diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index 2236e3d417e..6101c2bc05f 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -75,7 +75,6 @@ For further styling configurations, check out ConnectedFormGroup, like so: From 7d5ddfa13652052f065a55b1ecf2b84d1e602970 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 19 Sep 2025 10:50:49 -0400 Subject: [PATCH 10/25] PR feedback --- .../ConnectedForm/ConnectedForm.stories.tsx | 13 ++++++++----- .../ConnectedFormInputs/ConnectedFormInputs.mdx | 13 ++++++++----- .../src/lib/Organisms/GridForm/GridForm.stories.tsx | 13 ++++++++----- 3 files changed, 24 insertions(+), 15 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 bf2fe36c399..64d3d36797b 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -146,15 +146,18 @@ export const Default = () => { value: 'frontend', label: 'Frontend Technologies', options: [ - { value: 'react', label: 'React' }, { - value: 'vue', - label: 'Vue.js', + value: 'react', + label: 'React', options: [ - { value: 'test', label: 'Test' }, - { value: 'test2', label: 'Test2' }, + { value: 'nextjs', label: 'Next.js' }, + { value: 'typescript', label: 'TypeScript' }, ], }, + { + value: 'vue', + label: 'Vue.js', + }, { value: 'angular', label: 'Angular' }, ], }, diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index 6101c2bc05f..6ae3c4a2189 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -49,14 +49,17 @@ For further styling configurations, check out - { - handleCheckboxChange( - String(option.value), - event.target.checked, - selectedValues, - onChange - ); - }} - {...checkedProps} - {...ref} - /> - - ); - }, - [calculateStates, name, isRequired, isDisabled, handleCheckboxChange] - ); - - return ( - ( - - {flatOptions.map((option) => - renderCheckbox(option, value || [], onChange, onBlur, ref) - )} - - )} - rules={validation} - /> - ); -}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx new file mode 100644 index 00000000000..a077d47be49 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { useMemo } from 'react'; +import { Controller } from 'react-hook-form'; + +import { Box } from '../../..'; +import { useField } from '../..'; +import { ConnectedNestedCheckboxesProps } from '../types'; +import { + calculateStates, + flattenOptions, + handleCheckboxChange, + renderCheckbox, +} from './utils'; + +export const ConnectedNestedCheckboxes: React.FC< + ConnectedNestedCheckboxesProps +> = ({ name, options, disabled, onUpdate, spacing }) => { + const { isDisabled, control, validation, isRequired } = useField({ + name, + disabled, + }); + + const optionsWithSpacing = options.map((option) => ({ + ...option, + spacing, + })); + + const flatOptions = useMemo( + () => flattenOptions(optionsWithSpacing), + [optionsWithSpacing] + ); + + return ( + ( + + {flatOptions.map((option) => { + const states = calculateStates(value, flatOptions); + const state = states.get(option.value)!; + return renderCheckbox( + option, + state, + `${name}-${option.value}`, + isRequired, + isDisabled, + onBlur, + (event) => { + handleCheckboxChange( + option, + event.target.checked, + value, + flatOptions, + onChange, + onUpdate + ); + }, + ref + ); + })} + + )} + rules={validation} + /> + ); +}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx new file mode 100644 index 00000000000..96acb0371e0 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -0,0 +1,207 @@ +import { Box, Checkbox, CheckboxLabelUnion } from '../../..'; +import { NestedGridFormCheckboxOption } from '../../../GridForm/types'; +import { MinimalCheckboxProps, NestedConnectedCheckboxOption } from '../types'; + +type FlatCheckbox = Omit & + CheckboxLabelUnion & { + level: number; + parentValue?: string; + options: string[]; + value: string; + }; + +type FlatCheckboxState = Pick; + +export const flattenOptions = ( + opts: NestedConnectedCheckboxOption[] | NestedGridFormCheckboxOption[], + level = 0, + parentValue?: string +) => { + const result: FlatCheckbox[] = []; + + opts.forEach((option) => { + const optionValue = String(option.value); + const options = option.options + ? option.options.map((child) => String(child.value)) + : []; + + result.push({ + ...option, + value: optionValue, + level, + parentValue, + options, + }); + + if (option.options) { + result.push(...flattenOptions(option.options, level + 1, optionValue)); + } + }); + + return result; +}; + +export const getAllDescendants = ( + parentValue: string, + flatOptions: FlatCheckbox[] +) => { + const descendants: string[] = []; + + const collectDescendants = (currentParentValue: string) => { + flatOptions.forEach((option) => { + if (option.parentValue === currentParentValue) { + descendants.push(option.value); + collectDescendants(option.value); + } + }); + }; + + collectDescendants(parentValue); + return descendants; +}; + +export const calculateStates = ( + selectedValues: string[], + flatOptions: FlatCheckbox[] +) => { + const states = new Map(); + + // Initialize all states + flatOptions.forEach((option) => { + states.set(option.value, { + checked: selectedValues.includes(option.value), + }); + }); + + // Calculate parent states based on all descendants (infinite levels) + flatOptions.forEach((option) => { + if (option.options.length > 0) { + const allDescendants = getAllDescendants(option.value, flatOptions); + const checkedDescendants = allDescendants.filter((descendantValue) => + selectedValues.includes(descendantValue) + ); + + const state = states.get(option.value)!; + if (checkedDescendants.length === 0) { + state.checked = false; + state.indeterminate = false; + } else if (checkedDescendants.length === allDescendants.length) { + state.checked = true; + } else { + state.checked = false; + state.indeterminate = true; + } + } + }); + + return states; +}; + +export const handleCheckboxChange = ( + option: FlatCheckbox, + isChecked: boolean, + selectedValues: string[], + flatOptions: FlatCheckbox[], + onChange: (values: string[]) => void, + onUpdate?: (values: string[]) => void +) => { + const currentValue = option.value; + + let newSelectedValues = [...selectedValues]; + + if (option.options.length > 0) { + // Parent checkbox - get all descendants (infinite levels) + const allDescendants = getAllDescendants(currentValue, flatOptions); + + if (isChecked) { + // Add all descendants that aren't already selected + allDescendants.forEach((descendantValue) => { + if (!newSelectedValues.includes(descendantValue)) { + newSelectedValues.push(descendantValue); + } + }); + } else { + // Remove all descendants + newSelectedValues = newSelectedValues.filter( + (value) => !allDescendants.includes(value) + ); + } + } + + // Handle the current checkbox itself (for leaf nodes or when toggling individual items) + if (isChecked) { + if (!newSelectedValues.includes(currentValue)) { + newSelectedValues.push(currentValue); + } + } else { + newSelectedValues = newSelectedValues.filter( + (value) => value !== currentValue + ); + } + + onChange(newSelectedValues); + onUpdate?.(newSelectedValues); +}; + +export const renderCheckbox = ( + option: FlatCheckbox, + state: FlatCheckboxState, + checkboxId: string, + isRequired: boolean, + isDisabled: boolean, + onBlur: () => void, + onChange: (event: React.ChangeEvent) => void, + ref: React.RefCallback, + error?: boolean +) => { + let checkedProps = {}; + if (state.checked) { + checkedProps = { + checked: true, + 'aria-checked': true, + }; + } else if (state.indeterminate) { + checkedProps = { + indeterminate: true, + checked: false, + 'aria-checked': 'mixed', + }; + } else { + checkedProps = { + checked: false, + 'aria-checked': false, + }; + } + + return ( + + + + ); +}; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index c24582e2bfb..655ec04e3c8 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -1,264 +1,65 @@ import * as React from 'react'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { Controller } from 'react-hook-form'; -import { Box, Checkbox, CheckboxPaddingProps } from '../../..'; +import { Box } from '../../..'; import { - BaseFormInputProps, - GridFormNestedCheckboxField, - NestedGridFormCheckboxOption, -} from '../../types'; + calculateStates, + flattenOptions, + handleCheckboxChange, + renderCheckbox, +} from '../../../ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils'; +import { BaseFormInputProps, GridFormNestedCheckboxField } from '../../types'; export interface GridFormNestedCheckboxInputProps extends BaseFormInputProps { field: GridFormNestedCheckboxField; } -type FlatCheckboxState = Omit & - CheckboxPaddingProps & { - level: number; - parentValue?: string; - options: string[]; - }; - export const GridFormNestedCheckboxInput: React.FC< GridFormNestedCheckboxInputProps > = ({ field, required, disabled, error }) => { const isDisabled = disabled || field.disabled; - // Flatten the nested structure for easier state management - const flattenOptions = useCallback( - (opts: NestedGridFormCheckboxOption[], level = 0, parentValue?: string) => { - const result: FlatCheckboxState[] = []; - - opts.forEach((option) => { - // Ensure value is a string - const optionValue = String(option.value); - const options = option.options - ? option.options.map((child) => String(child.value)) - : []; - - result.push({ - ...option, - spacing: field.spacing, - value: optionValue, - level, - parentValue, - options, - }); - - if (option.options) { - result.push( - ...flattenOptions(option.options, level + 1, optionValue) - ); - } - }); - - return result; - }, - [field.spacing] - ); + const optionsWithSpacing = field.options.map((option) => ({ + ...option, + spacing: field.spacing, + })); const flatOptions = useMemo( - () => flattenOptions(field.options), - [field.options, flattenOptions] - ); - - // Helper function to get all descendants of a given option - const getAllDescendants = useCallback( - (parentValue: string) => { - const descendants: string[] = []; - - const collectDescendants = (currentParentValue: string) => { - flatOptions.forEach((option) => { - if (option.parentValue === currentParentValue) { - descendants.push(String(option.value)); - // Recursively collect descendants of this option - collectDescendants(String(option.value)); - } - }); - }; - - collectDescendants(parentValue); - return descendants; - }, - [flatOptions] - ); - - // Calculate checkbox states based on selected values - const calculateStates = useCallback( - (selectedValues: string[]) => { - const states = new Map(); - - // Initialize all states - flatOptions.forEach((option) => { - states.set(String(option.value), { - ...option, - checked: selectedValues.includes(String(option.value)), - }); - }); - - // Calculate parent states based on all descendants (infinite levels) - flatOptions.forEach((option) => { - if (option.options.length > 0) { - const allDescendants = getAllDescendants(String(option.value)); - const checkedDescendants = allDescendants.filter((descendantValue) => - selectedValues.includes(descendantValue) - ); - - const state = states.get(String(option.value))!; - if (checkedDescendants.length === 0) { - state.checked = false; - state.indeterminate = false; - } else if (checkedDescendants.length === allDescendants.length) { - state.checked = true; - } else { - state.checked = false; - state.indeterminate = true; - } - } - }); - - return states; - }, - [flatOptions, getAllDescendants] - ); - - const handleCheckboxChange = useCallback( - ( - currentValue: string, - isChecked: boolean, - selectedValues: string[], - onChange: (values: string[]) => void - ) => { - const option = flatOptions.find((opt) => opt.value === currentValue); - if (!option) return; - - let newSelectedValues = [...selectedValues]; - - if (option.options.length > 0) { - // Parent checkbox - toggle all descendants (infinite levels) - const allDescendants = getAllDescendants(currentValue); - - if (isChecked) { - // Add all descendants that aren't already selected - allDescendants.forEach((descendantValue) => { - if (!newSelectedValues.includes(descendantValue)) { - newSelectedValues.push(descendantValue); - } - }); - } else { - // Remove all descendants - newSelectedValues = newSelectedValues.filter( - (value) => !allDescendants.includes(value) - ); - } - } - - // Handle the current checkbox itself (for leaf nodes or when toggling individual items) - if (isChecked) { - if (!newSelectedValues.includes(currentValue)) { - newSelectedValues.push(currentValue); - } - } else { - newSelectedValues = newSelectedValues.filter( - (value) => value !== currentValue - ); - } - - onChange(newSelectedValues); - field.onUpdate?.(newSelectedValues); - }, - [flatOptions, field, getAllDescendants] - ); - - const renderCheckbox = useCallback( - ( - option: FlatCheckboxState, - selectedValues: string[], - onChange: (values: string[]) => void, - onBlur: () => void - ) => { - const states = calculateStates(selectedValues); - const state = states.get(String(option.value))!; - const checkboxId = field.id || `${field.name}-${option.value}`; - - let checkedProps = {}; - if (state.checked) { - checkedProps = { - checked: true, - 'aria-checked': true, - }; - } else if (state.indeterminate) { - checkedProps = { - indeterminate: true, - checked: false, - 'aria-checked': 'mixed', - }; - } else { - checkedProps = { - checked: false, - 'aria-checked': false, - }; - } - - return ( - - { - handleCheckboxChange( - String(option.value), - event.target.checked, - selectedValues, - onChange - ); - }} - {...checkedProps} - /> - - ); - }, - [ - calculateStates, - field.name, - field.id, - required, - isDisabled, - handleCheckboxChange, - error, - ] + () => flattenOptions(optionsWithSpacing), + [optionsWithSpacing] ); return ( ( + render={({ field: { value = [], onChange, onBlur, ref } }) => ( - {flatOptions.map((option) => - renderCheckbox(option, value || [], onChange, onBlur) - )} + {flatOptions.map((option) => { + const states = calculateStates(value, flatOptions); + const state = states.get(option.value)!; + return renderCheckbox( + option, + state, + `${field.name}-${option.value}`, + !!required, + !!isDisabled, + onBlur, + (event) => { + handleCheckboxChange( + option, + event.target.checked, + value, + flatOptions, + onChange, + field.onUpdate + ); + }, + ref, + error + ); + })} )} rules={field.validation} From a34b0b5a5b78e97770ac41ab51bc9a5a709164f8 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 19 Sep 2025 16:06:47 -0400 Subject: [PATCH 12/25] first stab at tests --- .../ConnectedNestedCheckboxes.test.tsx | 479 +++++++++++++ .../__tests__/utils.test.tsx | 669 ++++++++++++++++++ 2 files changed, 1148 insertions(+) create mode 100644 packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx create mode 100644 packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx new file mode 100644 index 00000000000..6016822aa34 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -0,0 +1,479 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent } from '@testing-library/dom'; +import { act } from '@testing-library/react'; + +import { ConnectedForm, ConnectedFormGroup, SubmitButton } from '../../..'; +import { NestedConnectedCheckboxOption } from '../../types'; +import { ConnectedNestedCheckboxes } from '../index'; + +const mockOptions: NestedConnectedCheckboxOption[] = [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue' }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { + value: 'node', + label: 'Node.js', + options: [ + { value: 'express', label: 'Express.js' }, + { value: 'fastify', label: 'Fastify' }, + ], + }, + { value: 'python', label: 'Python' }, + { value: 'ruby', label: 'Ruby', disabled: true }, + ], + }, + { + value: 'databases', + label: 'Databases', + }, +]; + +const mockOnUpdate = jest.fn(); +const TestForm: React.FC<{ + defaultValues?: { skills?: string[] }; + validationRules?: any; + disabled?: boolean; +}> = ({ defaultValues = {}, validationRules, disabled }) => ( + + + submit this form + +); + +const renderView = setupRtl(TestForm, {}); + +describe('ConnectedNestedCheckboxes', () => { + describe('rendering', () => { + it('should render all checkbox options in a flat list', () => { + const { view } = renderView(); + + // Top-level options + view.getByLabelText('Frontend Technologies'); + view.getByLabelText('Backend Technologies'); + view.getByLabelText('Databases'); + + // Frontend children + view.getByLabelText('React'); + view.getByLabelText('Vue'); + view.getByLabelText('Angular'); + + // Backend children + view.getByLabelText('Node.js'); + view.getByLabelText('Python'); + + // Deeply nested options + view.getByLabelText('Express.js'); + view.getByLabelText('Fastify'); + }); + + it('should render checkboxes with proper indentation levels', () => { + const { view } = renderView(); + + const frontendCheckbox = view + .getByLabelText('Frontend Technologies') + .closest('li'); + const reactCheckbox = view.getByLabelText('React').closest('li'); + const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); + const expressCheckbox = view.getByLabelText('Express.js').closest('li'); + + // Check margin-left styles for indentation + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0px' }); // level 0 + expect(reactCheckbox).toHaveStyle({ marginLeft: '24px' }); // level 1 + expect(nodeCheckbox).toHaveStyle({ marginLeft: '24px' }); // level 1 + expect(expressCheckbox).toHaveStyle({ marginLeft: '48px' }); // level 2 + }); + + it('should render with unique IDs for each checkbox', () => { + const { view } = renderView(); + + expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( + 'id', + 'skills-frontend' + ); + expect(view.getByLabelText('React')).toHaveAttribute( + 'id', + 'skills-react' + ); + expect(view.getByLabelText('Express.js')).toHaveAttribute( + 'id', + 'skills-express' + ); + }); + }); + + describe('default values', () => { + it('should render with default values checked', () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'python'] }, + }); + + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Vue')).not.toBeChecked(); + }); + + it('should render parent as indeterminate when some children are selected', () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'vue'] }, // only some frontend + }); + + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + + expect(frontendCheckbox.indeterminate).toBe(true); + expect(frontendCheckbox).not.toBeChecked(); + }); + + it('should render parent as checked when all children are selected', () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'vue', 'angular'] }, // all frontend + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + expect(frontendCheckbox).toBeChecked(); + }); + + it('should render deeply nested parent states correctly', () => { + const { view } = renderView({ + defaultValues: { skills: ['express', 'fastify'] }, // all node children + }); + + const nodeCheckbox = view.getByLabelText('Node.js'); + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + + expect(nodeCheckbox).toBeChecked(); // all children selected + expect(backendCheckbox.indeterminate).toBe(true); // only some children selected + }); + }); + + describe('user interactions', () => { + it('should update form value when leaf checkbox is clicked', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).toBeChecked(); + + // Verify parent state updates + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + expect(frontendCheckbox.indeterminate).toBe(true); + }); + + it('should select all children when parent checkbox is clicked', async () => { + const { view } = renderView({}); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(frontendCheckbox).toBeChecked(); + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Vue')).toBeChecked(); + expect(view.getByLabelText('Angular')).toBeChecked(); + }); + + it('should deselect all children when checked parent is clicked', async () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'vue', 'angular'] }, + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + expect(frontendCheckbox).toBeChecked(); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(frontendCheckbox).not.toBeChecked(); + expect(view.getByLabelText('React')).not.toBeChecked(); + expect(view.getByLabelText('Vue')).not.toBeChecked(); + expect(view.getByLabelText('Angular')).not.toBeChecked(); + }); + + it('should handle deeply nested selections correctly', async () => { + const { view } = renderView(); + + // Click Node.js parent (should select all its children) + const nodeCheckbox = view.getByLabelText('Node.js'); + + await act(async () => { + fireEvent.click(nodeCheckbox); + }); + + expect(nodeCheckbox).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + + // Backend should be indeterminate (only Node.js selected, not Python) + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + expect(backendCheckbox.indeterminate).toBe(true); + }); + + it('should handle individual child deselection affecting parent state', async () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'vue', 'angular'] }, + }); + + // Frontend should be fully checked + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + expect(frontendCheckbox).toBeChecked(); + + // Deselect one child + const reactCheckbox = view.getByLabelText('React'); + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).not.toBeChecked(); + expect(frontendCheckbox.indeterminate).toBe(true); + expect(frontendCheckbox).not.toBeChecked(); + }); + }); + + describe('onUpdate callback', () => { + it('should call onUpdate when checkbox values change', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith(['react']); + }); + + it('should call onUpdate with correct values when parent is selected', async () => { + const { view } = renderView(); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); + }); + + it('should call onUpdate with empty array when all items are deselected', async () => { + const { view } = renderView({ + defaultValues: { skills: ['react'] }, + }); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith([]); + }); + }); + + describe('disabled state', () => { + it('should render all checkboxes as disabled when disabled prop is true', () => { + const { view } = renderView({ disabled: true }); + + expect(view.getByLabelText('Frontend Technologies')).toBeDisabled(); + expect(view.getByLabelText('React')).toBeDisabled(); + expect(view.getByLabelText('Databases')).toBeDisabled(); + }); + + it('should not respond to clicks when disabled', async () => { + const { view } = renderView({ disabled: true }); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).not.toBeChecked(); + expect(mockOnUpdate).not.toHaveBeenCalled(); + }); + + it('should handle individual option disabled state', () => { + const { view } = renderView(); + + expect(view.getByLabelText('Ruby')).toBeDisabled(); + expect(view.getByLabelText('Vue')).not.toBeDisabled(); + }); + }); + + describe('validation', () => { + it('should handle required validation', async () => { + const validationRules = { + skills: { required: 'At least one skill is required' }, + }; + + const { view } = renderView({ validationRules }); + + await act(async () => { + fireEvent.click(view.getByRole('button')); + }); + + // Check if checkboxes have required attribute + expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( + 'aria-required', + 'true' + ); + }); + + it('should pass validation when items are selected', async () => { + const validationRules = { + skills: { required: 'At least one skill is required' }, + }; + + const { view } = renderView({ validationRules }); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).toBeChecked(); + expect(reactCheckbox).toHaveAttribute('aria-required', 'true'); + }); + }); + + describe('edge cases', () => { + it('should handle empty options array', () => { + const TestFormEmpty = () => ( + + + + ); + + const { view } = setupRtl(TestFormEmpty, {})({}); + + // Should render empty list + const list = view.container.querySelector('ul'); + expect(list).toBeInTheDocument(); + expect(list?.children).toHaveLength(0); + }); + + it('should handle options without nested children', () => { + const flatOptions: NestedConnectedCheckboxOption[] = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + const TestFormFlat = () => ( + + + + ); + + const { view } = setupRtl(TestFormFlat, {})({}); + + expect(view.getByLabelText('Option 1')).toBeInTheDocument(); + expect(view.getByLabelText('Option 2')).toBeInTheDocument(); + }); + + it('should handle numeric values correctly', () => { + const numericOptions: NestedConnectedCheckboxOption[] = [ + { + value: 1, + label: 'Parent Option', + options: [{ value: 2, label: 'Child Option' }], + } as any, // Type assertion for testing numeric values + ]; + + const TestFormNumeric = () => ( + + + + ); + + const { view } = setupRtl(TestFormNumeric, {})({}); + + expect(view.getByLabelText('Parent Option')).toHaveAttribute( + 'id', + 'skills-1' + ); + expect(view.getByLabelText('Child Option')).toHaveAttribute( + 'id', + 'skills-2' + ); + }); + }); + + describe('accessibility', () => { + it('should have proper aria attributes', () => { + const { view } = renderView({}); + + const checkbox = view.getByLabelText('Frontend Technologies'); + + expect(checkbox).toHaveAttribute('aria-label', 'Frontend Technologies'); + expect(checkbox).toHaveAttribute('aria-checked', 'false'); + }); + + it('should have proper aria-checked states for indeterminate checkboxes', () => { + const { view } = renderView({ + defaultValues: { skills: ['react'] }, // partial selection + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + expect(frontendCheckbox).toHaveAttribute('aria-checked', 'mixed'); + }); + + it('should use proper list semantics', () => { + const { view } = renderView({}); + + const list = view.container.querySelector('ul'); + const listItems = view.container.querySelectorAll('li'); + + expect(list).toHaveAttribute('role', 'list'); + expect(listItems).toHaveLength(8); // Total flattened options + + listItems.forEach((item) => { + expect(item).toHaveStyle({ listStyle: 'none' }); + }); + }); + }); +}); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx new file mode 100644 index 00000000000..e6b9ff13beb --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx @@ -0,0 +1,669 @@ +import { render } from '@testing-library/react'; + +import { + calculateStates, + flattenOptions, + getAllDescendants, + handleCheckboxChange, + renderCheckbox, +} from '../utils'; + +describe('ConnectedNestedCheckboxes utils', () => { + describe('flattenOptions', () => { + it('should flatten a single level of options', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + const result = flattenOptions(options); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + value: 'option1', + label: 'Option 1', + level: 0, + parentValue: undefined, + options: [], + }); + expect(result[1]).toMatchObject({ + value: 'option2', + label: 'Option 2', + level: 0, + parentValue: undefined, + options: [], + }); + }); + + it('should flatten nested options with correct levels and parent values', () => { + const options = [ + { + value: 'parent1', + label: 'Parent 1', + options: [ + { value: 'child1', label: 'Child 1' }, + { value: 'child2', label: 'Child 2' }, + ], + }, + { value: 'parent2', label: 'Parent 2' }, + ]; + + const result = flattenOptions(options); + + expect(result).toHaveLength(4); + + // Parent 1 + expect(result[0]).toMatchObject({ + value: 'parent1', + label: 'Parent 1', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + }); + + // Child 1 + expect(result[1]).toMatchObject({ + value: 'child1', + label: 'Child 1', + level: 1, + parentValue: 'parent1', + options: [], + }); + + // Child 2 + expect(result[2]).toMatchObject({ + value: 'child2', + label: 'Child 2', + level: 1, + parentValue: 'parent1', + options: [], + }); + + // Parent 2 + expect(result[3]).toMatchObject({ + value: 'parent2', + label: 'Parent 2', + level: 0, + parentValue: undefined, + options: [], + }); + }); + + it('should handle deeply nested options', () => { + const options = [ + { + value: 'level1', + label: 'Level 1', + options: [ + { + value: 'level2', + label: 'Level 2', + options: [{ value: 'level3', label: 'Level 3' }], + }, + ], + }, + ]; + + const result = flattenOptions(options); + + expect(result).toHaveLength(3); + expect(result[0].level).toBe(0); + expect(result[1].level).toBe(1); + expect(result[2].level).toBe(2); + expect(result[2].parentValue).toBe('level2'); + }); + + it('should handle empty options array', () => { + const result = flattenOptions([]); + expect(result).toEqual([]); + }); + + it('should convert numeric values to strings', () => { + const options = [{ value: 123, label: 'Numeric Option' }]; + + const result = flattenOptions(options as any); + + expect(result[0].value).toBe('123'); + }); + + it('should handle custom level and parentValue parameters', () => { + const options = [{ value: 'test', label: 'Test' }]; + + const result = flattenOptions(options, 2, 'customParent'); + + expect(result[0].level).toBe(2); + expect(result[0].parentValue).toBe('customParent'); + }); + }); + + describe('getAllDescendants', () => { + const flatOptions = [ + { + value: 'parent', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + label: 'Parent', + }, + { + value: 'child1', + level: 1, + parentValue: 'parent', + options: ['grandchild1'], + label: 'Child 1', + }, + { + value: 'child2', + level: 1, + parentValue: 'parent', + options: [], + label: 'Child 2', + }, + { + value: 'grandchild1', + level: 2, + parentValue: 'child1', + options: [], + label: 'Grandchild 1', + }, + { + value: 'orphan', + level: 0, + parentValue: undefined, + options: [], + label: 'Orphan', + }, + ]; + + it('should get all direct and indirect descendants', () => { + const result = getAllDescendants('parent', flatOptions); + expect(result).toEqual(['child1', 'child2', 'grandchild1']); + }); + + it('should get only direct descendants when no grandchildren exist', () => { + const result = getAllDescendants('child2', flatOptions); + expect(result).toEqual([]); + }); + + it('should get descendants for intermediate level nodes', () => { + const result = getAllDescendants('child1', flatOptions); + expect(result).toEqual(['grandchild1']); + }); + + it('should return empty array for leaf nodes', () => { + const result = getAllDescendants('grandchild1', flatOptions); + expect(result).toEqual([]); + }); + + it('should return empty array for non-existent parent', () => { + const result = getAllDescendants('nonexistent', flatOptions); + expect(result).toEqual([]); + }); + + it('should handle empty flatOptions array', () => { + const result = getAllDescendants('parent', []); + expect(result).toEqual([]); + }); + }); + + describe('calculateStates', () => { + const flatOptions = [ + { + value: 'parent1', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + label: 'Parent 1', + }, + { + value: 'child1', + level: 1, + parentValue: 'parent1', + options: ['grandchild1'], + label: 'Child 1', + }, + { + value: 'child2', + level: 1, + parentValue: 'parent1', + options: [], + label: 'Child 2', + }, + { + value: 'grandchild1', + level: 2, + parentValue: 'child1', + options: [], + label: 'Grandchild 1', + }, + { + value: 'parent2', + level: 0, + parentValue: undefined, + options: ['child3'], + label: 'Parent 2', + }, + { + value: 'child3', + level: 1, + parentValue: 'parent2', + options: [], + label: 'Child 3', + }, + ]; + + it('should set parent as checked when all descendants are selected', () => { + const selectedValues = ['child1', 'child2', 'grandchild1']; + const states = calculateStates(selectedValues, flatOptions); + + const parent1State = states.get('parent1'); + expect(parent1State).toEqual({ checked: true }); + }); + + it('should set parent as indeterminate when some descendants are selected', () => { + const selectedValues = ['child1', 'grandchild1']; + const states = calculateStates(selectedValues, flatOptions); + + const parent1State = states.get('parent1'); + expect(parent1State).toEqual({ checked: false, indeterminate: true }); + }); + + it('should set parent as unchecked when no descendants are selected', () => { + const selectedValues: string[] = []; + const states = calculateStates(selectedValues, flatOptions); + + const parent1State = states.get('parent1'); + expect(parent1State).toEqual({ checked: false, indeterminate: false }); + }); + + it('should set leaf nodes based on selection', () => { + const selectedValues = ['child2', 'grandchild1']; + const states = calculateStates(selectedValues, flatOptions); + + expect(states.get('child2')).toEqual({ checked: true }); + expect(states.get('grandchild1')).toEqual({ checked: true }); + expect(states.get('child3')).toEqual({ checked: false }); + }); + + it('should handle intermediate parent states correctly', () => { + const selectedValues = ['grandchild1']; + const states = calculateStates(selectedValues, flatOptions); + + const child1State = states.get('child1'); + expect(child1State).toEqual({ checked: true }); // all descendants (grandchild1) are selected + + const parent1State = states.get('parent1'); + expect(parent1State).toEqual({ checked: false, indeterminate: true }); // only some descendants selected + }); + + it('should handle empty selected values', () => { + const states = calculateStates([], flatOptions); + + flatOptions.forEach((option) => { + const state = states.get(option.value); + if (option.options.length > 0) { + expect(state).toEqual({ checked: false, indeterminate: false }); + } else { + expect(state).toEqual({ checked: false }); + } + }); + }); + + it('should handle all values selected', () => { + const allValues = flatOptions.map((opt) => opt.value); + const states = calculateStates(allValues, flatOptions); + + flatOptions.forEach((option) => { + const state = states.get(option.value); + expect(state?.checked).toBe(true); + }); + }); + }); + + describe('handleCheckboxChange', () => { + const flatOptions = [ + { + value: 'parent', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + label: 'Parent', + }, + { + value: 'child1', + level: 1, + parentValue: 'parent', + options: [], + label: 'Child 1', + }, + { + value: 'child2', + level: 1, + parentValue: 'parent', + options: [], + label: 'Child 2', + }, + { + value: 'standalone', + level: 0, + parentValue: undefined, + options: [], + label: 'Standalone', + }, + ]; + + it('should add all descendants when parent is checked', () => { + const onChange = jest.fn(); + const onUpdate = jest.fn(); + const parentOption = flatOptions[0]; + + handleCheckboxChange( + parentOption, + true, + [], + flatOptions, + onChange, + onUpdate + ); + + expect(onChange).toHaveBeenCalledWith(['child1', 'child2', 'parent']); + expect(onUpdate).toHaveBeenCalledWith(['child1', 'child2', 'parent']); + }); + + it('should remove all descendants when parent is unchecked', () => { + const onChange = jest.fn(); + const onUpdate = jest.fn(); + const parentOption = flatOptions[0]; + const initialValues = ['parent', 'child1', 'child2', 'standalone']; + + handleCheckboxChange( + parentOption, + false, + initialValues, + flatOptions, + onChange, + onUpdate + ); + + expect(onChange).toHaveBeenCalledWith(['standalone']); + expect(onUpdate).toHaveBeenCalledWith(['standalone']); + }); + + it('should add individual child when checked', () => { + const onChange = jest.fn(); + const childOption = flatOptions[1]; + + handleCheckboxChange(childOption, true, [], flatOptions, onChange); + + expect(onChange).toHaveBeenCalledWith(['child1']); + }); + + it('should remove individual child when unchecked', () => { + const onChange = jest.fn(); + const childOption = flatOptions[1]; + const initialValues = ['child1', 'child2']; + + handleCheckboxChange( + childOption, + false, + initialValues, + flatOptions, + onChange + ); + + expect(onChange).toHaveBeenCalledWith(['child2']); + }); + + it('should not duplicate values when adding already selected items', () => { + const onChange = jest.fn(); + const parentOption = flatOptions[0]; + const initialValues = ['child1']; + + handleCheckboxChange( + parentOption, + true, + initialValues, + flatOptions, + onChange + ); + + expect(onChange).toHaveBeenCalledWith(['child1', 'child2', 'parent']); + }); + + it('should handle onUpdate being undefined', () => { + const onChange = jest.fn(); + const childOption = flatOptions[1]; + + expect(() => { + handleCheckboxChange(childOption, true, [], flatOptions, onChange); + }).not.toThrow(); + + expect(onChange).toHaveBeenCalledWith(['child1']); + }); + + it('should handle leaf node selection without affecting other nodes', () => { + const onChange = jest.fn(); + const standaloneOption = flatOptions[3]; + + handleCheckboxChange( + standaloneOption, + true, + ['child1'], + flatOptions, + onChange + ); + + expect(onChange).toHaveBeenCalledWith(['child1', 'standalone']); + }); + }); + + describe('renderCheckbox', () => { + const mockOption = { + value: 'test', + label: 'Test Label', + level: 1, + parentValue: 'parent', + options: [], + }; + + const mockRef = jest.fn(); + const mockOnChange = jest.fn(); + const mockOnBlur = jest.fn(); + + it('should render a checked checkbox with correct props', () => { + const state = { checked: true }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + true, // isRequired + false, // isDisabled + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toBeChecked(); + expect(checkbox).toHaveAttribute('aria-checked', 'true'); + expect(checkbox).toHaveAttribute('aria-required', 'true'); + }); + + it('should render an indeterminate checkbox with correct props', () => { + const state = { checked: false, indeterminate: true }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + false, // isRequired + false, // isDisabled + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + expect(checkbox).toHaveAttribute('aria-checked', 'mixed'); + }); + + it('should render an unchecked checkbox with correct props', () => { + const state = { checked: false }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + false, // isRequired + false, // isDisabled + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + expect(checkbox).toHaveAttribute('aria-checked', 'false'); + }); + + it('should apply correct margin based on level', () => { + const state = { checked: false }; + + const result = renderCheckbox( + { ...mockOption, level: 2 }, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const listItem = container.querySelector('li'); + + expect(listItem).toHaveStyle({ marginLeft: '48px' }); // 2 * 24px + }); + + it('should handle disabled state', () => { + const state = { checked: false }; + + const result = renderCheckbox( + { ...mockOption, disabled: true }, + state, + 'test-id', + false, + true, // isDisabled + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toBeDisabled(); + }); + + it('should handle error state', () => { + const state = { checked: false }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef, + true // error + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + }); + + it('should use custom aria-label when provided', () => { + const state = { checked: false }; + const optionWithAriaLabel = { + ...mockOption, + 'aria-label': 'Custom aria label', + }; + + const result = renderCheckbox( + optionWithAriaLabel, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toHaveAttribute('aria-label', 'Custom aria label'); + }); + + it('should fallback to label text for aria-label when label is string', () => { + const state = { checked: false }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toHaveAttribute('aria-label', 'Test Label'); + }); + + it('should use default aria-label when label is not string', () => { + const state = { checked: false }; + const optionWithElementLabel = { + ...mockOption, + label: Element Label, + }; + + const result = renderCheckbox( + optionWithElementLabel, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toHaveAttribute('aria-label', 'checkbox'); + }); + }); +}); From 641aeb20bca0b7b2127ce0883e580d64b474bbb2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 23 Sep 2025 15:31:02 -0400 Subject: [PATCH 13/25] it works in gridform --- .../ConnectedNestedCheckboxes.test.tsx | 50 ++++++ .../ConnectedNestedCheckboxes/utils.tsx | 21 +++ .../GridFormNestedCheckboxInput.test.tsx | 133 ++++++++++++++ .../GridFormNestedCheckboxInput/index.tsx | 164 ++++++++++++++---- .../ConnectedForm/ConnectedForm.stories.tsx | 2 +- .../Organisms/GridForm/GridForm.stories.tsx | 1 + 6 files changed, 336 insertions(+), 35 deletions(-) create mode 100644 packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index 6016822aa34..d27c226a604 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -170,6 +170,56 @@ describe('ConnectedNestedCheckboxes', () => { expect(nodeCheckbox).toBeChecked(); // all children selected expect(backendCheckbox.indeterminate).toBe(true); // only some children selected }); + + it('should automatically check all children when parent is in default values', () => { + const { view } = renderView({ + defaultValues: { skills: ['backend'] }, // parent selected by default + }); + + // Parent should be checked + const backendCheckbox = view.getByLabelText('Backend Technologies'); + expect(backendCheckbox).toBeChecked(); + + // All children should be automatically checked + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + + // Deeply nested children should also be checked + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + }); + + it('should allow unchecking children that were auto-checked by default parent selection', async () => { + const { view } = renderView({ + defaultValues: { skills: ['backend'] }, // parent selected by default + }); + + // Initially all should be checked due to parent selection + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + const pythonCheckbox = view.getByLabelText('Python'); + + expect(backendCheckbox).toBeChecked(); + expect(pythonCheckbox).toBeChecked(); + + // User should be able to uncheck a child + await act(async () => { + fireEvent.click(pythonCheckbox); + }); + + // Python should now be unchecked + expect(pythonCheckbox).not.toBeChecked(); + + // Parent should now be indeterminate since not all children are checked + expect(backendCheckbox.indeterminate).toBe(true); + expect(backendCheckbox).not.toBeChecked(); + + // Other children should remain checked + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + }); }); describe('user interactions', () => { diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx index 96acb0371e0..bc79a8d9713 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -66,6 +66,21 @@ export const calculateStates = ( ) => { const states = new Map(); + // const expandedValues = [...selectedValues]; + + // // For each selected value, if it's a parent, add all its descendants + // selectedValues.forEach((selectedValue: string) => { + // const option = flatOptions.find((opt) => opt.value === selectedValue); + // if (option && option.options.length > 0) { + // const allDescendants = getAllDescendants(selectedValue, flatOptions); + // allDescendants.forEach((descendantValue) => { + // if (!expandedValues.includes(descendantValue)) { + // expandedValues.push(descendantValue); + // } + // }); + // } + // }); + // Initialize all states flatOptions.forEach((option) => { states.set(option.value, { @@ -94,6 +109,7 @@ export const calculateStates = ( } }); + console.log(states); return states; }; @@ -106,6 +122,7 @@ export const handleCheckboxChange = ( onUpdate?: (values: string[]) => void ) => { const currentValue = option.value; + // console.log(currentValue); let newSelectedValues = [...selectedValues]; @@ -128,6 +145,9 @@ export const handleCheckboxChange = ( } } + console.log(newSelectedValues); + console.log(currentValue); + console.log(isChecked); // Handle the current checkbox itself (for leaf nodes or when toggling individual items) if (isChecked) { if (!newSelectedValues.includes(currentValue)) { @@ -137,6 +157,7 @@ export const handleCheckboxChange = ( newSelectedValues = newSelectedValues.filter( (value) => value !== currentValue ); + console.log(newSelectedValues); } onChange(newSelectedValues); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx new file mode 100644 index 00000000000..71ed3813605 --- /dev/null +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -0,0 +1,133 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { act } from '@testing-library/react'; + +import { GridFormNestedCheckboxInput } from '../index'; + +const mockNestedCheckboxField = { + component: 'nested-checkboxes' as const, + name: 'technologies', + label: 'Technologies', + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue.js' }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], +}; + +const renderComponent = setupRtl(GridFormNestedCheckboxInput, { + field: mockNestedCheckboxField, +}); + +describe('GridFormNestedCheckboxInput', () => { + describe('default values', () => { + it('should render with basic options unchecked by default', () => { + const { view } = renderComponent(); + + expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); + expect(view.getByLabelText('Backend Technologies')).not.toBeChecked(); + expect(view.getByLabelText('React')).not.toBeChecked(); + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + expect(view.getByLabelText('Node.js')).not.toBeChecked(); + expect(view.getByLabelText('Python')).not.toBeChecked(); + }); + + it('should automatically check all children when parent is in default values', async () => { + const fieldWithDefaults = { + ...mockNestedCheckboxField, + defaultValue: ['backend'], // Parent selected by default + }; + + const { view } = renderComponent({ field: fieldWithDefaults }); + + // Wait for the expansion to happen (setTimeout in the component) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + // Parent should be checked + const backendCheckbox = view.getByLabelText('Backend Technologies'); + expect(backendCheckbox).toBeChecked(); + + // All children should be automatically checked + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Java')).toBeChecked(); + + // Frontend should remain unchecked + expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); + expect(view.getByLabelText('React')).not.toBeChecked(); + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + }); + + it('should handle multiple parent defaults correctly', async () => { + const fieldWithDefaults = { + ...mockNestedCheckboxField, + defaultValue: ['frontend', 'backend'], // Both parents selected by default + }; + + const { view } = renderComponent({ field: fieldWithDefaults }); + + // Wait for the expansion to happen + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + // Both parents should be checked + expect(view.getByLabelText('Frontend Technologies')).toBeChecked(); + expect(view.getByLabelText('Backend Technologies')).toBeChecked(); + + // All children should be automatically checked + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Vue.js')).toBeChecked(); + expect(view.getByLabelText('Angular')).toBeChecked(); + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Java')).toBeChecked(); + }); + + it('should preserve individual child selections in default values', () => { + const fieldWithDefaults = { + ...mockNestedCheckboxField, + defaultValue: ['react', 'python'], // Individual children selected + }; + + const { view } = renderComponent({ field: fieldWithDefaults }); + + // Selected children should be checked + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + + // Parents should be indeterminate since not all children are selected + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + + expect(frontendCheckbox.indeterminate).toBe(true); + expect(backendCheckbox.indeterminate).toBe(true); + + // Other children should remain unchecked + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + expect(view.getByLabelText('Angular')).not.toBeChecked(); + expect(view.getByLabelText('Node.js')).not.toBeChecked(); + expect(view.getByLabelText('Java')).not.toBeChecked(); + }); + }); +}); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index 655ec04e3c8..fb65015047e 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { useMemo } from 'react'; -import { Controller } from 'react-hook-form'; +import { useEffect, useMemo, useRef } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { Box } from '../../..'; import { calculateStates, flattenOptions, + getAllDescendants, handleCheckboxChange, renderCheckbox, } from '../../../ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils'; @@ -25,43 +26,138 @@ export const GridFormNestedCheckboxInput: React.FC< spacing: field.spacing, })); - const flatOptions = useMemo( - () => flattenOptions(optionsWithSpacing), - [optionsWithSpacing] + const flatOptions = useMemo(() => { + const flattened = flattenOptions(optionsWithSpacing); + console.log('flatOptions computed:', flattened); + return flattened; + }, [optionsWithSpacing]); + + // Helper function to expand values to include descendants of selected parents + const expandValues = React.useCallback( + (values: string[]): string[] => { + console.log('expandValues called with:', values); + const expandedValues = [...values]; + + // For each selected value, if it's a parent, add all its descendants + values.forEach((selectedValue: string) => { + const option = flatOptions.find((opt) => opt.value === selectedValue); + console.log(`Checking value ${selectedValue}:`, option); + if (option && option.options.length > 0) { + const allDescendants = getAllDescendants(selectedValue, flatOptions); + console.log(`Descendants for ${selectedValue}:`, allDescendants); + allDescendants.forEach((descendantValue) => { + if (!expandedValues.includes(descendantValue)) { + expandedValues.push(descendantValue); + console.log(`Added descendant: ${descendantValue}`); + } + }); + } + }); + + console.log('expandValues result:', expandedValues); + return expandedValues; + }, + [flatOptions] ); + // Track if we've done initial expansion + const hasExpandedInitially = useRef(false); + const { setValue } = useFormContext(); + + // Extract field properties for stable dependencies + const fieldName = field.name; + const fieldDefaultValue = field.defaultValue; + const fieldOnUpdate = field.onUpdate; + + // Handle expansion in useEffect instead of render function + useEffect(() => { + if (hasExpandedInitially.current) return; + + // Get current form value + const currentFormValue = fieldDefaultValue || []; + console.log('useEffect expansion check:', { + fieldName, + currentFormValue, + flatOptionsLength: flatOptions.length, + }); + + if (currentFormValue.length > 0) { + const needsExpansion = currentFormValue.some((selectedValue: string) => { + const option = flatOptions.find((opt) => opt.value === selectedValue); + if (option && option.options.length > 0) { + const allDescendants = getAllDescendants(selectedValue, flatOptions); + const hasAllDescendants = allDescendants.every((descendant) => + currentFormValue.includes(descendant) + ); + console.log( + `Value ${selectedValue} needs expansion:`, + !hasAllDescendants + ); + return !hasAllDescendants; + } + return false; + }); + + if (needsExpansion) { + const expandedValues = expandValues(currentFormValue); + console.log('useEffect EXPANDING:', { + original: currentFormValue, + expanded: expandedValues, + }); + + // Use setValue to update the form state + setValue(fieldName, expandedValues); + fieldOnUpdate?.(expandedValues); + hasExpandedInitially.current = true; + } + } + }, [ + fieldName, + fieldDefaultValue, + fieldOnUpdate, + flatOptions, + expandValues, + setValue, + ]); + return ( ( - - {flatOptions.map((option) => { - const states = calculateStates(value, flatOptions); - const state = states.get(option.value)!; - return renderCheckbox( - option, - state, - `${field.name}-${option.value}`, - !!required, - !!isDisabled, - onBlur, - (event) => { - handleCheckboxChange( - option, - event.target.checked, - value, - flatOptions, - onChange, - field.onUpdate - ); - }, - ref, - error - ); - })} - - )} + render={({ field: { value = [], onChange, onBlur, ref } }) => { + console.log('GridForm render:', { + fieldName: field.name, + value, + }); + + const states = calculateStates(value, flatOptions); + return ( + + {flatOptions.map((option) => { + const state = states.get(option.value)!; + return renderCheckbox( + option, + state, + `${field.name}-${option.value}`, + !!required, + !!isDisabled, + onBlur, + (event) => { + handleCheckboxChange( + option, + event.target.checked, + value, + flatOptions, + onChange, + field.onUpdate + ); + }, + ref, + error + ); + })} + + ); + }} rules={field.validation} /> ); 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 64d3d36797b..43e9fe0bdb0 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -32,7 +32,7 @@ export const Default = () => { inputField: '', radioGroupField: undefined, textAreaField: '', - nestedCheckboxesField: [], + nestedCheckboxesField: ['react', 'typescript', 'backend'], }, validationRules: { checkboxField: { required: 'you need to check this.' }, diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index 1b616dfab7b..4bd283f0cf5 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -120,6 +120,7 @@ const meta: Meta = { label: 'Nested checkboxes', name: 'nested-checkboxes', type: 'nested-checkboxes', + defaultValue: ['backend', 'react', 'vue'], options: [ { value: 'frontend', From 86152dce513a53d9d669ea8530ca9551b453bc25 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 23 Sep 2025 15:55:02 -0400 Subject: [PATCH 14/25] clean up logs and comments --- .../ConnectedNestedCheckboxes/utils.tsx | 22 ----------------- .../GridFormNestedCheckboxInput/index.tsx | 24 ------------------- 2 files changed, 46 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx index bc79a8d9713..2b3b4aa96ba 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -66,21 +66,6 @@ export const calculateStates = ( ) => { const states = new Map(); - // const expandedValues = [...selectedValues]; - - // // For each selected value, if it's a parent, add all its descendants - // selectedValues.forEach((selectedValue: string) => { - // const option = flatOptions.find((opt) => opt.value === selectedValue); - // if (option && option.options.length > 0) { - // const allDescendants = getAllDescendants(selectedValue, flatOptions); - // allDescendants.forEach((descendantValue) => { - // if (!expandedValues.includes(descendantValue)) { - // expandedValues.push(descendantValue); - // } - // }); - // } - // }); - // Initialize all states flatOptions.forEach((option) => { states.set(option.value, { @@ -109,7 +94,6 @@ export const calculateStates = ( } }); - console.log(states); return states; }; @@ -122,8 +106,6 @@ export const handleCheckboxChange = ( onUpdate?: (values: string[]) => void ) => { const currentValue = option.value; - // console.log(currentValue); - let newSelectedValues = [...selectedValues]; if (option.options.length > 0) { @@ -145,9 +127,6 @@ export const handleCheckboxChange = ( } } - console.log(newSelectedValues); - console.log(currentValue); - console.log(isChecked); // Handle the current checkbox itself (for leaf nodes or when toggling individual items) if (isChecked) { if (!newSelectedValues.includes(currentValue)) { @@ -157,7 +136,6 @@ export const handleCheckboxChange = ( newSelectedValues = newSelectedValues.filter( (value) => value !== currentValue ); - console.log(newSelectedValues); } onChange(newSelectedValues); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index fb65015047e..693c661a895 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -28,33 +28,27 @@ export const GridFormNestedCheckboxInput: React.FC< const flatOptions = useMemo(() => { const flattened = flattenOptions(optionsWithSpacing); - console.log('flatOptions computed:', flattened); return flattened; }, [optionsWithSpacing]); // Helper function to expand values to include descendants of selected parents const expandValues = React.useCallback( (values: string[]): string[] => { - console.log('expandValues called with:', values); const expandedValues = [...values]; // For each selected value, if it's a parent, add all its descendants values.forEach((selectedValue: string) => { const option = flatOptions.find((opt) => opt.value === selectedValue); - console.log(`Checking value ${selectedValue}:`, option); if (option && option.options.length > 0) { const allDescendants = getAllDescendants(selectedValue, flatOptions); - console.log(`Descendants for ${selectedValue}:`, allDescendants); allDescendants.forEach((descendantValue) => { if (!expandedValues.includes(descendantValue)) { expandedValues.push(descendantValue); - console.log(`Added descendant: ${descendantValue}`); } }); } }); - console.log('expandValues result:', expandedValues); return expandedValues; }, [flatOptions] @@ -75,11 +69,6 @@ export const GridFormNestedCheckboxInput: React.FC< // Get current form value const currentFormValue = fieldDefaultValue || []; - console.log('useEffect expansion check:', { - fieldName, - currentFormValue, - flatOptionsLength: flatOptions.length, - }); if (currentFormValue.length > 0) { const needsExpansion = currentFormValue.some((selectedValue: string) => { @@ -89,10 +78,6 @@ export const GridFormNestedCheckboxInput: React.FC< const hasAllDescendants = allDescendants.every((descendant) => currentFormValue.includes(descendant) ); - console.log( - `Value ${selectedValue} needs expansion:`, - !hasAllDescendants - ); return !hasAllDescendants; } return false; @@ -100,10 +85,6 @@ export const GridFormNestedCheckboxInput: React.FC< if (needsExpansion) { const expandedValues = expandValues(currentFormValue); - console.log('useEffect EXPANDING:', { - original: currentFormValue, - expanded: expandedValues, - }); // Use setValue to update the form state setValue(fieldName, expandedValues); @@ -124,11 +105,6 @@ export const GridFormNestedCheckboxInput: React.FC< { - console.log('GridForm render:', { - fieldName: field.name, - value, - }); - const states = calculateStates(value, flatOptions); return ( From 5108c8cc4c510b9f25a4d8b239496dba82b6d9f8 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 24 Sep 2025 17:29:47 -0400 Subject: [PATCH 15/25] fix defaultValue type issue --- packages/gamut/src/ConnectedForm/utils.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/utils.tsx b/packages/gamut/src/ConnectedForm/utils.tsx index 75d52876dca..95560991c7f 100644 --- a/packages/gamut/src/ConnectedForm/utils.tsx +++ b/packages/gamut/src/ConnectedForm/utils.tsx @@ -14,6 +14,7 @@ import { FieldError, FieldErrorsImpl, Merge, + Path, RegisterOptions, useFieldArray, useFormContext, @@ -44,8 +45,8 @@ interface UseConnectedFormProps< defaultValues: Values; validationRules: Partial; watchedFields?: { - fields: (keyof Values)[]; - watchHandler: (arg0: (keyof Values)[]) => void; + fields: Path[]; + watchHandler: (arg0: Path[]) => void; }; } @@ -159,9 +160,10 @@ export const useField = ({ name, disabled, loading }: useFieldProps) => { const { control, errors, - register, + getValues, isDisabled: formStateDisabled, isSoloField, + register, setError, setValue, validationRules, @@ -184,6 +186,7 @@ export const useField = ({ name, disabled, loading }: useFieldProps) => { return { control, error, + getValues, isDisabled, /** * Keep track of the first error in this form. From f67e05d33262eda54ea7188bf3a76760efc28c4e Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 24 Sep 2025 17:29:57 -0400 Subject: [PATCH 16/25] types refactor --- .../gamut/src/ConnectedForm/ConnectedInputs/types.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 11fd80a251d..87bf6f02138 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -42,10 +42,7 @@ export interface ConnectedRadioProps export interface ConnectedBaseRadioGroupProps extends FieldComponent {} -export type ConnectedBaseRadioInputProps = Omit< - RadioProps, - 'defaultValue' | 'name' | 'validation' -> & { +export type ConnectedBaseRadioInputProps = FieldComponent & { label: ReactNode; value: string | number; }; @@ -61,11 +58,11 @@ export interface ConnectedRadioGroupInputProps } export interface ConnectedSelectProps - extends Omit, + extends FieldComponent, ConnectedFieldProps {} export interface ConnectedTextAreaProps - extends Omit, + extends FieldComponent, ConnectedFieldProps {} export type NestedConnectedCheckboxOption = Omit< From fea9287d78d5dc532a2ff675759aca1e91bf7353 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 24 Sep 2025 17:30:42 -0400 Subject: [PATCH 17/25] gridform default value --- .../GridFormNestedCheckboxInput/index.tsx | 92 +++++-------------- packages/gamut/src/GridForm/utils.ts | 2 + 2 files changed, 24 insertions(+), 70 deletions(-) diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index 693c661a895..aab0adf07dc 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { useEffect, useMemo, useRef } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; +import { useEffect, useMemo, useState } from 'react'; +import { Controller } from 'react-hook-form'; import { Box } from '../../..'; import { @@ -11,14 +11,16 @@ import { renderCheckbox, } from '../../../ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils'; import { BaseFormInputProps, GridFormNestedCheckboxField } from '../../types'; +import { GridFormInputGroupProps } from '..'; export interface GridFormNestedCheckboxInputProps extends BaseFormInputProps { field: GridFormNestedCheckboxField; + setValue: GridFormInputGroupProps['setValue']; } export const GridFormNestedCheckboxInput: React.FC< GridFormNestedCheckboxInputProps -> = ({ field, required, disabled, error }) => { +> = ({ field, required, disabled, error, setValue }) => { const isDisabled = disabled || field.disabled; const optionsWithSpacing = field.options.map((option) => ({ @@ -31,75 +33,25 @@ export const GridFormNestedCheckboxInput: React.FC< return flattened; }, [optionsWithSpacing]); - // Helper function to expand values to include descendants of selected parents - const expandValues = React.useCallback( - (values: string[]): string[] => { - const expandedValues = [...values]; + const [hasExpandedInitially, setHasExpandedInitially] = useState(false); - // For each selected value, if it's a parent, add all its descendants - values.forEach((selectedValue: string) => { - const option = flatOptions.find((opt) => opt.value === selectedValue); - if (option && option.options.length > 0) { - const allDescendants = getAllDescendants(selectedValue, flatOptions); - allDescendants.forEach((descendantValue) => { - if (!expandedValues.includes(descendantValue)) { - expandedValues.push(descendantValue); - } - }); - } - }); - - return expandedValues; - }, - [flatOptions] - ); - - // Track if we've done initial expansion - const hasExpandedInitially = useRef(false); - const { setValue } = useFormContext(); - - // Extract field properties for stable dependencies - const fieldName = field.name; - const fieldDefaultValue = field.defaultValue; - const fieldOnUpdate = field.onUpdate; - - // Handle expansion in useEffect instead of render function useEffect(() => { - if (hasExpandedInitially.current) return; - - // Get current form value - const currentFormValue = fieldDefaultValue || []; - - if (currentFormValue.length > 0) { - const needsExpansion = currentFormValue.some((selectedValue: string) => { - const option = flatOptions.find((opt) => opt.value === selectedValue); - if (option && option.options.length > 0) { - const allDescendants = getAllDescendants(selectedValue, flatOptions); - const hasAllDescendants = allDescendants.every((descendant) => - currentFormValue.includes(descendant) - ); - return !hasAllDescendants; - } - return false; - }); - - if (needsExpansion) { - const expandedValues = expandValues(currentFormValue); - - // Use setValue to update the form state - setValue(fieldName, expandedValues); - fieldOnUpdate?.(expandedValues); - hasExpandedInitially.current = true; - } - } - }, [ - fieldName, - fieldDefaultValue, - fieldOnUpdate, - flatOptions, - expandValues, - setValue, - ]); + if ( + hasExpandedInitially || + !field.defaultValue || + field.defaultValue.length === 0 + ) + return; + + const expandedValues = [...field.defaultValue]; + field.defaultValue.forEach((value) => + expandedValues.push(...getAllDescendants(value, flatOptions)) + ); + + setValue(field.name, expandedValues); + field.onUpdate?.(expandedValues); // do we want to do this? + setHasExpandedInitially(true); + }, [hasExpandedInitially, field, flatOptions, setValue]); return ( { case 'text': case 'textarea': return ''; + case 'nested-checkboxes': + return []; default: break; } From e2e3cc62d1b3a75de0ef15bf96a61b999bbe46b0 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 24 Sep 2025 17:31:01 -0400 Subject: [PATCH 18/25] connectedform default value --- .../ConnectedNestedCheckboxes/index.tsx | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index a077d47be49..4192f5fcb0f 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Controller } from 'react-hook-form'; import { Box } from '../../..'; @@ -8,6 +8,7 @@ import { ConnectedNestedCheckboxesProps } from '../types'; import { calculateStates, flattenOptions, + getAllDescendants, handleCheckboxChange, renderCheckbox, } from './utils'; @@ -15,10 +16,13 @@ import { export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps > = ({ name, options, disabled, onUpdate, spacing }) => { - const { isDisabled, control, validation, isRequired } = useField({ - name, - disabled, - }); + const { isDisabled, control, validation, isRequired, getValues, setValue } = + useField({ + name, + disabled, + }); + + const defaultValue: string[] = getValues()[name]; const optionsWithSpacing = options.map((option) => ({ ...option, @@ -30,10 +34,32 @@ export const ConnectedNestedCheckboxes: React.FC< [optionsWithSpacing] ); + const [hasExpandedInitially, setHasExpandedInitially] = useState(false); + + useEffect(() => { + if (hasExpandedInitially || !defaultValue || defaultValue.length === 0) + return; + + const expandedValues = [...defaultValue]; + defaultValue.forEach((value) => + expandedValues.push(...getAllDescendants(value, flatOptions)) + ); + + setValue(name, expandedValues); + onUpdate?.(expandedValues); // do we want to do this? + setHasExpandedInitially(true); + }, [ + hasExpandedInitially, + flatOptions, + setValue, + defaultValue, + name, + onUpdate, + ]); + return ( ( From 8c65ca0a5bcc5c4092f7e3a3573f8286fa5877c2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 25 Sep 2025 15:44:11 -0400 Subject: [PATCH 19/25] update connectedform --- .../ConnectedNestedCheckboxes/index.tsx | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index 4192f5fcb0f..187a406fa33 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -61,33 +61,35 @@ export const ConnectedNestedCheckboxes: React.FC< ( - - {flatOptions.map((option) => { - const states = calculateStates(value, flatOptions); - const state = states.get(option.value)!; - return renderCheckbox( - option, - state, - `${name}-${option.value}`, - isRequired, - isDisabled, - onBlur, - (event) => { - handleCheckboxChange( - option, - event.target.checked, - value, - flatOptions, - onChange, - onUpdate - ); - }, - ref - ); - })} - - )} + render={({ field: { value = [], onChange, onBlur, ref } }) => { + const states = calculateStates(value, flatOptions); + return ( + + {flatOptions.map((option) => { + const state = states.get(option.value)!; + return renderCheckbox( + option, + state, + `${name}-${option.value}`, + isRequired, + isDisabled, + onBlur, + (event) => { + handleCheckboxChange( + option, + event.target.checked, + value, + flatOptions, + onChange, + onUpdate + ); + }, + ref + ); + })} + + ); + }} rules={validation} /> ); From d6b147e5c56080cbf62acb60627bd018e822b463 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 25 Sep 2025 15:44:16 -0400 Subject: [PATCH 20/25] add passing tests --- .../ConnectedNestedCheckboxes.test.tsx | 97 +-- .../__tests__/utils.test.tsx | 16 +- .../GridFormNestedCheckboxInput.test.tsx | 579 ++++++++++++++++-- 3 files changed, 573 insertions(+), 119 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index d27c226a604..2eb8c9ff26b 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -1,6 +1,7 @@ import { setupRtl } from '@codecademy/gamut-tests'; import { fireEvent } from '@testing-library/dom'; import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ConnectedForm, ConnectedFormGroup, SubmitButton } from '../../..'; import { NestedConnectedCheckboxOption } from '../../types'; @@ -43,7 +44,13 @@ const TestForm: React.FC<{ defaultValues?: { skills?: string[] }; validationRules?: any; disabled?: boolean; -}> = ({ defaultValues = {}, validationRules, disabled }) => ( + options?: NestedConnectedCheckboxOption[]; +}> = ({ + defaultValues = {}, + validationRules, + disabled, + options = mockOptions, +}) => ( { const expressCheckbox = view.getByLabelText('Express.js').closest('li'); // Check margin-left styles for indentation - expect(frontendCheckbox).toHaveStyle({ marginLeft: '0px' }); // level 0 - expect(reactCheckbox).toHaveStyle({ marginLeft: '24px' }); // level 1 - expect(nodeCheckbox).toHaveStyle({ marginLeft: '24px' }); // level 1 - expect(expressCheckbox).toHaveStyle({ marginLeft: '48px' }); // level 2 + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); // level 0 + expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 + expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 + expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); // level 2 }); it('should render with unique IDs for each checkbox', () => { @@ -137,7 +144,7 @@ describe('ConnectedNestedCheckboxes', () => { it('should render parent as indeterminate when some children are selected', () => { const { view } = renderView({ - defaultValues: { skills: ['react', 'vue'] }, // only some frontend + defaultValues: { skills: ['react', 'vue'] }, }); const frontendCheckbox = view.getByLabelText( @@ -159,7 +166,7 @@ describe('ConnectedNestedCheckboxes', () => { it('should render deeply nested parent states correctly', () => { const { view } = renderView({ - defaultValues: { skills: ['express', 'fastify'] }, // all node children + defaultValues: { skills: ['express', 'fastify'] }, }); const nodeCheckbox = view.getByLabelText('Node.js'); @@ -187,6 +194,16 @@ describe('ConnectedNestedCheckboxes', () => { // Deeply nested children should also be checked expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); + + // onUpdate should have been called with all expanded values during initialization + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'backend', + 'node', + 'express', + 'fastify', + 'python', + 'ruby', + ]); }); it('should allow unchecking children that were auto-checked by default parent selection', async () => { @@ -373,13 +390,15 @@ describe('ConnectedNestedCheckboxes', () => { }); it('should not respond to clicks when disabled', async () => { - const { view } = renderView({ disabled: true }); + const { view } = renderView({ + disabled: true, + }); const reactCheckbox = view.getByLabelText('React'); + expect(reactCheckbox).toBeDisabled(); + expect(reactCheckbox).not.toBeChecked(); - await act(async () => { - fireEvent.click(reactCheckbox); - }); + await userEvent.click(reactCheckbox); expect(reactCheckbox).not.toBeChecked(); expect(mockOnUpdate).not.toHaveBeenCalled(); @@ -432,13 +451,7 @@ describe('ConnectedNestedCheckboxes', () => { describe('edge cases', () => { it('should handle empty options array', () => { - const TestFormEmpty = () => ( - - - - ); - - const { view } = setupRtl(TestFormEmpty, {})({}); + const { view } = renderView({ options: [] }); // Should render empty list const list = view.container.querySelector('ul'); @@ -447,39 +460,27 @@ describe('ConnectedNestedCheckboxes', () => { }); it('should handle options without nested children', () => { - const flatOptions: NestedConnectedCheckboxOption[] = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, - ]; - - const TestFormFlat = () => ( - - - - ); - - const { view } = setupRtl(TestFormFlat, {})({}); + const { view } = renderView({ + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ], + }); expect(view.getByLabelText('Option 1')).toBeInTheDocument(); expect(view.getByLabelText('Option 2')).toBeInTheDocument(); }); it('should handle numeric values correctly', () => { - const numericOptions: NestedConnectedCheckboxOption[] = [ - { - value: 1, - label: 'Parent Option', - options: [{ value: 2, label: 'Child Option' }], - } as any, // Type assertion for testing numeric values - ]; - - const TestFormNumeric = () => ( - - - - ); - - const { view } = setupRtl(TestFormNumeric, {})({}); + const { view } = renderView({ + options: [ + { + value: 1, + label: 'Parent Option', + options: [{ value: 2, label: 'Child Option' }], + } as any, // Type assertion for testing numeric values + ], + }); expect(view.getByLabelText('Parent Option')).toHaveAttribute( 'id', @@ -518,8 +519,8 @@ describe('ConnectedNestedCheckboxes', () => { const list = view.container.querySelector('ul'); const listItems = view.container.querySelectorAll('li'); - expect(list).toHaveAttribute('role', 'list'); - expect(listItems).toHaveLength(8); // Total flattened options + expect(list).toBeInTheDocument(); + expect(listItems).toHaveLength(11); // Total flattened options listItems.forEach((item) => { expect(item).toHaveStyle({ listStyle: 'none' }); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx index e6b9ff13beb..573c620e02c 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx @@ -51,8 +51,6 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = flattenOptions(options); expect(result).toHaveLength(4); - - // Parent 1 expect(result[0]).toMatchObject({ value: 'parent1', label: 'Parent 1', @@ -60,8 +58,6 @@ describe('ConnectedNestedCheckboxes utils', () => { parentValue: undefined, options: ['child1', 'child2'], }); - - // Child 1 expect(result[1]).toMatchObject({ value: 'child1', label: 'Child 1', @@ -69,8 +65,6 @@ describe('ConnectedNestedCheckboxes utils', () => { parentValue: 'parent1', options: [], }); - - // Child 2 expect(result[2]).toMatchObject({ value: 'child2', label: 'Child 2', @@ -78,8 +72,6 @@ describe('ConnectedNestedCheckboxes utils', () => { parentValue: 'parent1', options: [], }); - - // Parent 2 expect(result[3]).toMatchObject({ value: 'parent2', label: 'Parent 2', @@ -177,7 +169,7 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should get all direct and indirect descendants', () => { const result = getAllDescendants('parent', flatOptions); - expect(result).toEqual(['child1', 'child2', 'grandchild1']); + expect(result).toEqual(['child1', 'grandchild1', 'child2']); }); it('should get only direct descendants when no grandchildren exist', () => { @@ -290,10 +282,10 @@ describe('ConnectedNestedCheckboxes utils', () => { const states = calculateStates(selectedValues, flatOptions); const child1State = states.get('child1'); - expect(child1State).toEqual({ checked: true }); // all descendants (grandchild1) are selected + expect(child1State).toEqual({ checked: true }); const parent1State = states.get('parent1'); - expect(parent1State).toEqual({ checked: false, indeterminate: true }); // only some descendants selected + expect(parent1State).toEqual({ checked: false, indeterminate: true }); }); it('should handle empty selected values', () => { @@ -650,7 +642,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }; const result = renderCheckbox( - optionWithElementLabel, + optionWithElementLabel as any, // ts should prevent this from ever happening but we have a default just in case state, 'test-id', false, diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx index 71ed3813605..0aff9879f3e 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -1,42 +1,138 @@ import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent } from '@testing-library/dom'; import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; -import { GridFormNestedCheckboxInput } from '../index'; - -const mockNestedCheckboxField = { - component: 'nested-checkboxes' as const, - name: 'technologies', - label: 'Technologies', - options: [ - { - value: 'frontend', - label: 'Frontend Technologies', - options: [ - { value: 'react', label: 'React' }, - { value: 'vue', label: 'Vue.js' }, - { value: 'angular', label: 'Angular' }, - ], - }, - { - value: 'backend', - label: 'Backend Technologies', - options: [ - { value: 'node', label: 'Node.js' }, - { value: 'python', label: 'Python' }, - { value: 'java', label: 'Java' }, - ], - }, - ], -}; - -const renderComponent = setupRtl(GridFormNestedCheckboxInput, { - field: mockNestedCheckboxField, -}); +import { GridForm } from '../../../GridForm'; +import type { NestedGridFormCheckboxOption } from '../../../types'; + +const mockOptions: NestedGridFormCheckboxOption[] = [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue.js' }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { + value: 'node', + label: 'Node.js', + options: [ + { value: 'express', label: 'Express.js' }, + { value: 'fastify', label: 'Fastify' }, + ], + }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java', disabled: true }, + ], + }, + { + value: 'databases', + label: 'Databases', + }, +]; + +const mockOnUpdate = jest.fn(); +const TestForm: React.FC<{ + defaultValue?: string[]; + disabled?: boolean; + customError?: string; + options?: NestedGridFormCheckboxOption[]; +}> = ({ defaultValue = [], disabled, customError, options = mockOptions }) => ( + +); + +const renderView = setupRtl(TestForm, {}); describe('GridFormNestedCheckboxInput', () => { + describe('rendering', () => { + it('should render all checkbox options in a flat list', () => { + const { view } = renderView(); + + // Top-level options + view.getByLabelText('Frontend Technologies'); + view.getByLabelText('Backend Technologies'); + view.getByLabelText('Databases'); + + // Frontend children + view.getByLabelText('React'); + view.getByLabelText('Vue.js'); + view.getByLabelText('Angular'); + + // Backend children + view.getByLabelText('Node.js'); + view.getByLabelText('Python'); + + // Deeply nested options + view.getByLabelText('Express.js'); + view.getByLabelText('Fastify'); + }); + + it('should render checkboxes with proper indentation levels', () => { + const { view } = renderView(); + + const frontendCheckbox = view + .getByLabelText('Frontend Technologies') + .closest('li'); + const reactCheckbox = view.getByLabelText('React').closest('li'); + const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); + const expressCheckbox = view.getByLabelText('Express.js').closest('li'); + + // Check margin-left styles for indentation + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); // level 0 + expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 + expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 + expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); // level 2 + }); + + it('should render with unique IDs for each checkbox', () => { + const { view } = renderView(); + + expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( + 'id', + 'technologies-frontend' + ); + expect(view.getByLabelText('React')).toHaveAttribute( + 'id', + 'technologies-react' + ); + expect(view.getByLabelText('Express.js')).toHaveAttribute( + 'id', + 'technologies-express' + ); + }); + }); + describe('default values', () => { - it('should render with basic options unchecked by default', () => { - const { view } = renderComponent(); + it('should render with options unchecked by default', () => { + const { view } = renderView(); expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); expect(view.getByLabelText('Backend Technologies')).not.toBeChecked(); @@ -46,17 +142,54 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Python')).not.toBeChecked(); }); - it('should automatically check all children when parent is in default values', async () => { - const fieldWithDefaults = { - ...mockNestedCheckboxField, - defaultValue: ['backend'], // Parent selected by default - }; + it('should render with default values checked', () => { + const { view } = renderView({ + defaultValue: ['react', 'python'], + }); - const { view } = renderComponent({ field: fieldWithDefaults }); + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + }); - // Wait for the expansion to happen (setTimeout in the component) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); + it('should render parent as indeterminate when some children are selected', () => { + const { view } = renderView({ + defaultValue: ['react', 'vue'], + }); + + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + + expect(frontendCheckbox.indeterminate).toBe(true); + expect(frontendCheckbox).not.toBeChecked(); + }); + + it('should render parent as checked when all children are selected', () => { + const { view } = renderView({ + defaultValue: ['react', 'vue', 'angular'], + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + expect(frontendCheckbox).toBeChecked(); + }); + + it('should render deeply nested parent states correctly', () => { + const { view } = renderView({ + defaultValue: ['express', 'fastify'], + }); + const nodeCheckbox = view.getByLabelText('Node.js'); + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + + expect(nodeCheckbox).toBeChecked(); // all children selected + expect(backendCheckbox.indeterminate).toBe(true); // only some children selected + }); + + it('should automatically check all children when parent is in default values', async () => { + const { view } = renderView({ + defaultValue: ['backend'], }); // Parent should be checked @@ -68,23 +201,29 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Python')).toBeChecked(); expect(view.getByLabelText('Java')).toBeChecked(); + // Deeply nested children should also be checked + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + // Frontend should remain unchecked expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); expect(view.getByLabelText('React')).not.toBeChecked(); expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + + // onUpdate should also have been called with all expanded values + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'backend', + 'node', + 'express', + 'fastify', + 'python', + 'java', + ]); }); it('should handle multiple parent defaults correctly', async () => { - const fieldWithDefaults = { - ...mockNestedCheckboxField, - defaultValue: ['frontend', 'backend'], // Both parents selected by default - }; - - const { view } = renderComponent({ field: fieldWithDefaults }); - - // Wait for the expansion to happen - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); + const { view } = renderView({ + defaultValue: ['frontend', 'backend'], }); // Both parents should be checked @@ -98,16 +237,14 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); expect(view.getByLabelText('Java')).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); }); it('should preserve individual child selections in default values', () => { - const fieldWithDefaults = { - ...mockNestedCheckboxField, - defaultValue: ['react', 'python'], // Individual children selected - }; - - const { view } = renderComponent({ field: fieldWithDefaults }); - + const { view } = renderView({ + defaultValue: ['react', 'python'], + }); // Selected children should be checked expect(view.getByLabelText('React')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); @@ -128,6 +265,330 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Angular')).not.toBeChecked(); expect(view.getByLabelText('Node.js')).not.toBeChecked(); expect(view.getByLabelText('Java')).not.toBeChecked(); + expect(view.getByLabelText('Express.js')).not.toBeChecked(); + expect(view.getByLabelText('Fastify')).not.toBeChecked(); + }); + + it('should allow unchecking children that were auto-checked by default parent selection', async () => { + const { view } = renderView({ + defaultValue: ['backend'], + }); + + // Initially all should be checked due to parent selection + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + const pythonCheckbox = view.getByLabelText('Python'); + + expect(backendCheckbox).toBeChecked(); + expect(pythonCheckbox).toBeChecked(); + + // User should be able to uncheck a child + await act(async () => { + fireEvent.click(pythonCheckbox); + }); + + // Python should now be unchecked + expect(pythonCheckbox).not.toBeChecked(); + + // Parent should now be indeterminate since not all children are checked + expect(backendCheckbox.indeterminate).toBe(true); + expect(backendCheckbox).not.toBeChecked(); + + // Other children should remain checked + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + }); + }); + + describe('user interactions', () => { + it('should update form value when leaf checkbox is clicked', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).toBeChecked(); + + // Verify parent state updates + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + expect(frontendCheckbox.indeterminate).toBe(true); + }); + + it('should select all children when parent checkbox is clicked', async () => { + const { view } = renderView(); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(frontendCheckbox).toBeChecked(); + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Vue.js')).toBeChecked(); + expect(view.getByLabelText('Angular')).toBeChecked(); + + // Verify onUpdate was called with correct values + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); + }); + + it('should deselect all children when checked parent is clicked', async () => { + const { view } = renderView({ + defaultValue: ['react', 'vue', 'angular'], + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + expect(frontendCheckbox).toBeChecked(); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(frontendCheckbox).not.toBeChecked(); + expect(view.getByLabelText('React')).not.toBeChecked(); + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + expect(view.getByLabelText('Angular')).not.toBeChecked(); + }); + + it('should handle deeply nested selections correctly', async () => { + const { view } = renderView(); + + // Click Node.js parent (should select all its children) + const nodeCheckbox = view.getByLabelText('Node.js'); + + await act(async () => { + fireEvent.click(nodeCheckbox); + }); + + expect(nodeCheckbox).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + + // Backend should be indeterminate (only Node.js selected, not Python) + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + expect(backendCheckbox.indeterminate).toBe(true); + + // Verify onUpdate was called with correct values + expect(mockOnUpdate).toHaveBeenCalledWith(['express', 'fastify', 'node']); + }); + + it('should handle individual child deselection affecting parent state', async () => { + const { view } = renderView({ + defaultValue: ['react', 'vue', 'angular'], + }); + + // Frontend should be fully checked + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + expect(frontendCheckbox).toBeChecked(); + + // Deselect one child + const reactCheckbox = view.getByLabelText('React'); + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).not.toBeChecked(); + expect(frontendCheckbox.indeterminate).toBe(true); + expect(frontendCheckbox).not.toBeChecked(); + }); + }); + + describe('onUpdate callback', () => { + it('should call onUpdate when checkbox values change', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith(['react']); + }); + + it('should call onUpdate with correct values when parent is selected', async () => { + const { view } = renderView(); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); + }); + + it('should call onUpdate with empty array when all items are deselected', async () => { + const { view } = renderView({ + defaultValue: ['react'], + }); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith([]); + }); + }); + + describe('disabled state', () => { + it('should render all checkboxes as disabled when disabled prop is true', () => { + const { view } = renderView({ disabled: true }); + + expect(view.getByLabelText('Frontend Technologies')).toBeDisabled(); + expect(view.getByLabelText('React')).toBeDisabled(); + expect(view.getByLabelText('Databases')).toBeDisabled(); + }); + + it('should not respond to clicks when disabled', async () => { + const { view } = renderView({ disabled: true }); + + const reactCheckbox = view.getByLabelText('React'); + expect(reactCheckbox).toBeDisabled(); + expect(reactCheckbox).not.toBeChecked(); + + await userEvent.click(reactCheckbox); + + expect(reactCheckbox).not.toBeChecked(); + expect(mockOnUpdate).not.toHaveBeenCalled(); + }); + + it('should handle individual option disabled state', () => { + const { view } = renderView(); + + expect(view.getByLabelText('Java')).toBeDisabled(); + expect(view.getByLabelText('Vue.js')).not.toBeDisabled(); + }); + }); + + describe('validation', () => { + it('should handle required validation', async () => { + const { view } = renderView(); + + // Check if checkboxes have required attribute + expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( + 'aria-required', + 'true' + ); + expect(view.getByLabelText('React')).toHaveAttribute( + 'aria-required', + 'true' + ); + }); + + it('should display error state when error prop is passed', () => { + const { view } = renderView({ + customError: 'Please select at least one option', + }); + + const checkboxes = view.container.querySelectorAll( + 'input[type="checkbox"]' + ); + checkboxes.forEach((checkbox) => { + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + }); + }); + }); + + describe('accessibility', () => { + it('should have proper aria attributes', () => { + const { view } = renderView(); + + const checkbox = view.getByLabelText('Frontend Technologies'); + + expect(checkbox).toHaveAttribute('aria-label', 'Frontend Technologies'); + expect(checkbox).toHaveAttribute('aria-checked', 'false'); + }); + + it('should have proper aria-checked states for indeterminate checkboxes', () => { + const { view } = renderView({ + defaultValue: ['react'], + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + expect(frontendCheckbox).toHaveAttribute('aria-checked', 'mixed'); + }); + + it('should use proper list semantics', () => { + const { view } = renderView(); + + const list = view.container.querySelector('ul'); + const listItems = view.container.querySelectorAll('li'); + + expect(list).toBeInTheDocument(); + expect(listItems).toHaveLength(11); // Total flattened options + + listItems.forEach((item) => { + expect(item).toHaveStyle({ listStyle: 'none' }); + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty options array', () => { + const { view } = renderView({ options: [] }); + + // Should render empty list + const list = view.container.querySelector('ul'); + expect(list).toBeInTheDocument(); + expect(list?.children).toHaveLength(0); + }); + + it('should handle options without nested children', () => { + const { view } = renderView({ + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ], + }); + + expect(view.getByLabelText('Option 1')).toBeInTheDocument(); + expect(view.getByLabelText('Option 2')).toBeInTheDocument(); + }); + + it('should handle numeric values correctly', () => { + const { view } = renderView({ + options: [ + { + value: 1, + label: 'Parent Option', + options: [{ value: 2, label: 'Child Option' }], + } as any, // Type assertion for testing numeric values + ], + }); + + expect(view.getByLabelText('Parent Option')).toHaveAttribute( + 'id', + 'technologies-1' + ); + expect(view.getByLabelText('Child Option')).toHaveAttribute( + 'id', + 'technologies-2' + ); }); }); }); From 569443903ff2ac8554dcb811ca77964eec5303c4 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 26 Sep 2025 14:39:30 -0400 Subject: [PATCH 21/25] clean up tests --- .../ConnectedNestedCheckboxes.test.tsx | 114 +++++++++++------- .../GridFormNestedCheckboxInput.test.tsx | 54 ++------- 2 files changed, 82 insertions(+), 86 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index 2eb8c9ff26b..ea3d7c90438 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -77,21 +77,14 @@ describe('ConnectedNestedCheckboxes', () => { it('should render all checkbox options in a flat list', () => { const { view } = renderView(); - // Top-level options view.getByLabelText('Frontend Technologies'); view.getByLabelText('Backend Technologies'); view.getByLabelText('Databases'); - - // Frontend children view.getByLabelText('React'); view.getByLabelText('Vue'); view.getByLabelText('Angular'); - - // Backend children view.getByLabelText('Node.js'); view.getByLabelText('Python'); - - // Deeply nested options view.getByLabelText('Express.js'); view.getByLabelText('Fastify'); }); @@ -106,11 +99,10 @@ describe('ConnectedNestedCheckboxes', () => { const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); const expressCheckbox = view.getByLabelText('Express.js').closest('li'); - // Check margin-left styles for indentation - expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); // level 0 - expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 - expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 - expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); // level 2 + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); + expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); + expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); + expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); }); it('should render with unique IDs for each checkbox', () => { @@ -157,7 +149,7 @@ describe('ConnectedNestedCheckboxes', () => { it('should render parent as checked when all children are selected', () => { const { view } = renderView({ - defaultValues: { skills: ['react', 'vue', 'angular'] }, // all frontend + defaultValues: { skills: ['react', 'vue', 'angular'] }, }); const frontendCheckbox = view.getByLabelText('Frontend Technologies'); @@ -169,33 +161,30 @@ describe('ConnectedNestedCheckboxes', () => { defaultValues: { skills: ['express', 'fastify'] }, }); - const nodeCheckbox = view.getByLabelText('Node.js'); + const nodeCheckbox = view.getByLabelText('Node.js') as HTMLInputElement; const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; - expect(nodeCheckbox).toBeChecked(); // all children selected - expect(backendCheckbox.indeterminate).toBe(true); // only some children selected + expect(nodeCheckbox).toBeChecked(); + expect(nodeCheckbox.indeterminate).toBe(false); + expect(backendCheckbox).not.toBeChecked(); + expect(backendCheckbox.indeterminate).toBe(true); }); it('should automatically check all children when parent is in default values', () => { const { view } = renderView({ - defaultValues: { skills: ['backend'] }, // parent selected by default + defaultValues: { skills: ['backend'] }, }); - // Parent should be checked const backendCheckbox = view.getByLabelText('Backend Technologies'); expect(backendCheckbox).toBeChecked(); - // All children should be automatically checked expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); - - // Deeply nested children should also be checked expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - // onUpdate should have been called with all expanded values during initialization expect(mockOnUpdate).toHaveBeenCalledWith([ 'backend', 'node', @@ -206,12 +195,55 @@ describe('ConnectedNestedCheckboxes', () => { ]); }); + it('should handle multiple parent defaults correctly', async () => { + const { view } = renderView({ + defaultValues: { skills: ['frontend', 'backend'] }, + }); + + expect(view.getByLabelText('Frontend Technologies')).toBeChecked(); + expect(view.getByLabelText('Backend Technologies')).toBeChecked(); + + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Vue')).toBeChecked(); + expect(view.getByLabelText('Angular')).toBeChecked(); + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Ruby')).toBeChecked(); + }); + + it('should preserve individual child selections in default values', () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'python'] }, + }); + + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + + expect(frontendCheckbox.indeterminate).toBe(true); + expect(backendCheckbox.indeterminate).toBe(true); + + expect(view.getByLabelText('Vue')).not.toBeChecked(); + expect(view.getByLabelText('Angular')).not.toBeChecked(); + expect(view.getByLabelText('Node.js')).not.toBeChecked(); + expect(view.getByLabelText('Express.js')).not.toBeChecked(); + expect(view.getByLabelText('Fastify')).not.toBeChecked(); + expect(view.getByLabelText('Ruby')).not.toBeChecked(); + }); + it('should allow unchecking children that were auto-checked by default parent selection', async () => { const { view } = renderView({ - defaultValues: { skills: ['backend'] }, // parent selected by default + defaultValues: { skills: ['backend'] }, }); - // Initially all should be checked due to parent selection const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; @@ -220,19 +252,15 @@ describe('ConnectedNestedCheckboxes', () => { expect(backendCheckbox).toBeChecked(); expect(pythonCheckbox).toBeChecked(); - // User should be able to uncheck a child await act(async () => { fireEvent.click(pythonCheckbox); }); - // Python should now be unchecked expect(pythonCheckbox).not.toBeChecked(); - // Parent should now be indeterminate since not all children are checked expect(backendCheckbox.indeterminate).toBe(true); expect(backendCheckbox).not.toBeChecked(); - // Other children should remain checked expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); @@ -251,7 +279,6 @@ describe('ConnectedNestedCheckboxes', () => { expect(reactCheckbox).toBeChecked(); - // Verify parent state updates const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; @@ -259,7 +286,7 @@ describe('ConnectedNestedCheckboxes', () => { }); it('should select all children when parent checkbox is clicked', async () => { - const { view } = renderView({}); + const { view } = renderView(); const frontendCheckbox = view.getByLabelText('Frontend Technologies'); @@ -271,6 +298,13 @@ describe('ConnectedNestedCheckboxes', () => { expect(view.getByLabelText('React')).toBeChecked(); expect(view.getByLabelText('Vue')).toBeChecked(); expect(view.getByLabelText('Angular')).toBeChecked(); + + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); }); it('should deselect all children when checked parent is clicked', async () => { @@ -294,7 +328,6 @@ describe('ConnectedNestedCheckboxes', () => { it('should handle deeply nested selections correctly', async () => { const { view } = renderView(); - // Click Node.js parent (should select all its children) const nodeCheckbox = view.getByLabelText('Node.js'); await act(async () => { @@ -305,11 +338,12 @@ describe('ConnectedNestedCheckboxes', () => { expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - // Backend should be indeterminate (only Node.js selected, not Python) const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; expect(backendCheckbox.indeterminate).toBe(true); + + expect(mockOnUpdate).toHaveBeenCalledWith(['express', 'fastify', 'node']); }); it('should handle individual child deselection affecting parent state', async () => { @@ -317,13 +351,11 @@ describe('ConnectedNestedCheckboxes', () => { defaultValues: { skills: ['react', 'vue', 'angular'] }, }); - // Frontend should be fully checked const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; expect(frontendCheckbox).toBeChecked(); - // Deselect one child const reactCheckbox = view.getByLabelText('React'); await act(async () => { fireEvent.click(reactCheckbox); @@ -420,11 +452,6 @@ describe('ConnectedNestedCheckboxes', () => { const { view } = renderView({ validationRules }); - await act(async () => { - fireEvent.click(view.getByRole('button')); - }); - - // Check if checkboxes have required attribute expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( 'aria-required', 'true' @@ -453,7 +480,6 @@ describe('ConnectedNestedCheckboxes', () => { it('should handle empty options array', () => { const { view } = renderView({ options: [] }); - // Should render empty list const list = view.container.querySelector('ul'); expect(list).toBeInTheDocument(); expect(list?.children).toHaveLength(0); @@ -467,8 +493,8 @@ describe('ConnectedNestedCheckboxes', () => { ], }); - expect(view.getByLabelText('Option 1')).toBeInTheDocument(); - expect(view.getByLabelText('Option 2')).toBeInTheDocument(); + view.getByLabelText('Option 1'); + view.getByLabelText('Option 2'); }); it('should handle numeric values correctly', () => { @@ -495,7 +521,7 @@ describe('ConnectedNestedCheckboxes', () => { describe('accessibility', () => { it('should have proper aria attributes', () => { - const { view } = renderView({}); + const { view } = renderView(); const checkbox = view.getByLabelText('Frontend Technologies'); @@ -505,7 +531,7 @@ describe('ConnectedNestedCheckboxes', () => { it('should have proper aria-checked states for indeterminate checkboxes', () => { const { view } = renderView({ - defaultValues: { skills: ['react'] }, // partial selection + defaultValues: { skills: ['react'] }, }); const frontendCheckbox = view.getByLabelText('Frontend Technologies'); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx index 0aff9879f3e..7963a725886 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -76,21 +76,14 @@ describe('GridFormNestedCheckboxInput', () => { it('should render all checkbox options in a flat list', () => { const { view } = renderView(); - // Top-level options view.getByLabelText('Frontend Technologies'); view.getByLabelText('Backend Technologies'); view.getByLabelText('Databases'); - - // Frontend children view.getByLabelText('React'); view.getByLabelText('Vue.js'); view.getByLabelText('Angular'); - - // Backend children view.getByLabelText('Node.js'); view.getByLabelText('Python'); - - // Deeply nested options view.getByLabelText('Express.js'); view.getByLabelText('Fastify'); }); @@ -105,11 +98,10 @@ describe('GridFormNestedCheckboxInput', () => { const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); const expressCheckbox = view.getByLabelText('Express.js').closest('li'); - // Check margin-left styles for indentation - expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); // level 0 - expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 - expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 - expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); // level 2 + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); + expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); + expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); + expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); }); it('should render with unique IDs for each checkbox', () => { @@ -178,13 +170,15 @@ describe('GridFormNestedCheckboxInput', () => { const { view } = renderView({ defaultValue: ['express', 'fastify'], }); - const nodeCheckbox = view.getByLabelText('Node.js'); + const nodeCheckbox = view.getByLabelText('Node.js') as HTMLInputElement; const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; - expect(nodeCheckbox).toBeChecked(); // all children selected - expect(backendCheckbox.indeterminate).toBe(true); // only some children selected + expect(nodeCheckbox).toBeChecked(); + expect(nodeCheckbox.indeterminate).toBe(false); + expect(backendCheckbox).not.toBeChecked(); + expect(backendCheckbox.indeterminate).toBe(true); }); it('should automatically check all children when parent is in default values', async () => { @@ -192,25 +186,19 @@ describe('GridFormNestedCheckboxInput', () => { defaultValue: ['backend'], }); - // Parent should be checked const backendCheckbox = view.getByLabelText('Backend Technologies'); expect(backendCheckbox).toBeChecked(); - // All children should be automatically checked expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); expect(view.getByLabelText('Java')).toBeChecked(); - - // Deeply nested children should also be checked expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - // Frontend should remain unchecked expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); expect(view.getByLabelText('React')).not.toBeChecked(); expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - // onUpdate should also have been called with all expanded values expect(mockOnUpdate).toHaveBeenCalledWith([ 'backend', 'node', @@ -226,11 +214,9 @@ describe('GridFormNestedCheckboxInput', () => { defaultValue: ['frontend', 'backend'], }); - // Both parents should be checked expect(view.getByLabelText('Frontend Technologies')).toBeChecked(); expect(view.getByLabelText('Backend Technologies')).toBeChecked(); - // All children should be automatically checked expect(view.getByLabelText('React')).toBeChecked(); expect(view.getByLabelText('Vue.js')).toBeChecked(); expect(view.getByLabelText('Angular')).toBeChecked(); @@ -245,11 +231,10 @@ describe('GridFormNestedCheckboxInput', () => { const { view } = renderView({ defaultValue: ['react', 'python'], }); - // Selected children should be checked + expect(view.getByLabelText('React')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); - // Parents should be indeterminate since not all children are selected const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; @@ -260,7 +245,6 @@ describe('GridFormNestedCheckboxInput', () => { expect(frontendCheckbox.indeterminate).toBe(true); expect(backendCheckbox.indeterminate).toBe(true); - // Other children should remain unchecked expect(view.getByLabelText('Vue.js')).not.toBeChecked(); expect(view.getByLabelText('Angular')).not.toBeChecked(); expect(view.getByLabelText('Node.js')).not.toBeChecked(); @@ -274,7 +258,6 @@ describe('GridFormNestedCheckboxInput', () => { defaultValue: ['backend'], }); - // Initially all should be checked due to parent selection const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; @@ -283,19 +266,15 @@ describe('GridFormNestedCheckboxInput', () => { expect(backendCheckbox).toBeChecked(); expect(pythonCheckbox).toBeChecked(); - // User should be able to uncheck a child await act(async () => { fireEvent.click(pythonCheckbox); }); - // Python should now be unchecked expect(pythonCheckbox).not.toBeChecked(); - // Parent should now be indeterminate since not all children are checked expect(backendCheckbox.indeterminate).toBe(true); expect(backendCheckbox).not.toBeChecked(); - // Other children should remain checked expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); @@ -314,7 +293,6 @@ describe('GridFormNestedCheckboxInput', () => { expect(reactCheckbox).toBeChecked(); - // Verify parent state updates const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; @@ -335,7 +313,6 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Vue.js')).toBeChecked(); expect(view.getByLabelText('Angular')).toBeChecked(); - // Verify onUpdate was called with correct values expect(mockOnUpdate).toHaveBeenCalledWith([ 'react', 'vue', @@ -365,7 +342,6 @@ describe('GridFormNestedCheckboxInput', () => { it('should handle deeply nested selections correctly', async () => { const { view } = renderView(); - // Click Node.js parent (should select all its children) const nodeCheckbox = view.getByLabelText('Node.js'); await act(async () => { @@ -376,13 +352,11 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - // Backend should be indeterminate (only Node.js selected, not Python) const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; expect(backendCheckbox.indeterminate).toBe(true); - // Verify onUpdate was called with correct values expect(mockOnUpdate).toHaveBeenCalledWith(['express', 'fastify', 'node']); }); @@ -391,13 +365,11 @@ describe('GridFormNestedCheckboxInput', () => { defaultValue: ['react', 'vue', 'angular'], }); - // Frontend should be fully checked const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; expect(frontendCheckbox).toBeChecked(); - // Deselect one child const reactCheckbox = view.getByLabelText('React'); await act(async () => { fireEvent.click(reactCheckbox); @@ -488,7 +460,6 @@ describe('GridFormNestedCheckboxInput', () => { it('should handle required validation', async () => { const { view } = renderView(); - // Check if checkboxes have required attribute expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( 'aria-required', 'true' @@ -552,7 +523,6 @@ describe('GridFormNestedCheckboxInput', () => { it('should handle empty options array', () => { const { view } = renderView({ options: [] }); - // Should render empty list const list = view.container.querySelector('ul'); expect(list).toBeInTheDocument(); expect(list?.children).toHaveLength(0); @@ -566,8 +536,8 @@ describe('GridFormNestedCheckboxInput', () => { ], }); - expect(view.getByLabelText('Option 1')).toBeInTheDocument(); - expect(view.getByLabelText('Option 2')).toBeInTheDocument(); + view.getByLabelText('Option 1'); + view.getByLabelText('Option 2'); }); it('should handle numeric values correctly', () => { From 82d259835e15c283204396b178d785269dc0d316 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 14 Oct 2025 14:58:28 -0400 Subject: [PATCH 22/25] PR feedback --- .../ConnectedNestedCheckboxes.test.tsx | 50 ++-- .../__tests__/utils.test.tsx | 240 +++++++++--------- .../ConnectedNestedCheckboxes/index.tsx | 21 +- .../ConnectedNestedCheckboxes/utils.tsx | 59 +++-- .../GridFormNestedCheckboxInput.test.tsx | 55 ++-- .../GridFormNestedCheckboxInput/index.tsx | 25 +- 6 files changed, 259 insertions(+), 191 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index ea3d7c90438..95301bddba8 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -40,6 +40,7 @@ const mockOptions: NestedConnectedCheckboxOption[] = [ ]; const mockOnUpdate = jest.fn(); +const mockOnSubmit = jest.fn(); const TestForm: React.FC<{ defaultValues?: { skills?: string[] }; validationRules?: any; @@ -54,7 +55,7 @@ const TestForm: React.FC<{ { expect(view.getByLabelText('Python')).toBeChecked(); expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - - expect(mockOnUpdate).toHaveBeenCalledWith([ - 'backend', - 'node', - 'express', - 'fastify', - 'python', - 'ruby', - ]); }); it('should handle multiple parent defaults correctly', async () => { @@ -458,7 +450,7 @@ describe('ConnectedNestedCheckboxes', () => { ); }); - it('should pass validation when items are selected', async () => { + it('should submit successfully when validation passes', async () => { const validationRules = { skills: { required: 'At least one skill is required' }, }; @@ -471,8 +463,31 @@ describe('ConnectedNestedCheckboxes', () => { fireEvent.click(reactCheckbox); }); - expect(reactCheckbox).toBeChecked(); - expect(reactCheckbox).toHaveAttribute('aria-required', 'true'); + const submitButton = view.getByRole('button'); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(mockOnSubmit).toHaveBeenCalledWith( + { skills: ['react'] }, + expect.any(Object) + ); + }); + + it('should show validation errors and not submit when validation fails', async () => { + const validationRules = { + skills: { required: 'At least one skill is required' }, + }; + + const { view } = renderView({ validationRules }); + + const submitButton = view.getByRole('button'); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + view.getByText('At least one skill is required'); }); }); @@ -543,13 +558,12 @@ describe('ConnectedNestedCheckboxes', () => { const { view } = renderView({}); const list = view.container.querySelector('ul'); - const listItems = view.container.querySelectorAll('li'); - expect(list).toBeInTheDocument(); - expect(listItems).toHaveLength(11); // Total flattened options - listItems.forEach((item) => { - expect(item).toHaveStyle({ listStyle: 'none' }); + expect(list?.children).toHaveLength(11); // Total flattened options + Array.from(list?.children || []).forEach((item) => { + // each child of the ul should be an li + expect(item).toBeInstanceOf(HTMLLIElement); }); }); }); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx index 573c620e02c..79dc050a681 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx @@ -349,14 +349,14 @@ describe('ConnectedNestedCheckboxes utils', () => { const onUpdate = jest.fn(); const parentOption = flatOptions[0]; - handleCheckboxChange( - parentOption, - true, - [], + handleCheckboxChange({ + option: parentOption, + isChecked: true, + selectedValues: [], flatOptions, onChange, - onUpdate - ); + onUpdate, + }); expect(onChange).toHaveBeenCalledWith(['child1', 'child2', 'parent']); expect(onUpdate).toHaveBeenCalledWith(['child1', 'child2', 'parent']); @@ -368,14 +368,14 @@ describe('ConnectedNestedCheckboxes utils', () => { const parentOption = flatOptions[0]; const initialValues = ['parent', 'child1', 'child2', 'standalone']; - handleCheckboxChange( - parentOption, - false, - initialValues, + handleCheckboxChange({ + option: parentOption, + isChecked: false, + selectedValues: initialValues, flatOptions, onChange, - onUpdate - ); + onUpdate, + }); expect(onChange).toHaveBeenCalledWith(['standalone']); expect(onUpdate).toHaveBeenCalledWith(['standalone']); @@ -385,7 +385,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const onChange = jest.fn(); const childOption = flatOptions[1]; - handleCheckboxChange(childOption, true, [], flatOptions, onChange); + handleCheckboxChange({ + option: childOption, + isChecked: true, + selectedValues: [], + flatOptions, + onChange, + }); expect(onChange).toHaveBeenCalledWith(['child1']); }); @@ -395,13 +401,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const childOption = flatOptions[1]; const initialValues = ['child1', 'child2']; - handleCheckboxChange( - childOption, - false, - initialValues, + handleCheckboxChange({ + option: childOption, + isChecked: false, + selectedValues: initialValues, flatOptions, - onChange - ); + onChange, + }); expect(onChange).toHaveBeenCalledWith(['child2']); }); @@ -411,13 +417,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const parentOption = flatOptions[0]; const initialValues = ['child1']; - handleCheckboxChange( - parentOption, - true, - initialValues, + handleCheckboxChange({ + option: parentOption, + isChecked: true, + selectedValues: initialValues, flatOptions, - onChange - ); + onChange, + }); expect(onChange).toHaveBeenCalledWith(['child1', 'child2', 'parent']); }); @@ -427,7 +433,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const childOption = flatOptions[1]; expect(() => { - handleCheckboxChange(childOption, true, [], flatOptions, onChange); + handleCheckboxChange({ + option: childOption, + isChecked: true, + selectedValues: [], + flatOptions, + onChange, + }); }).not.toThrow(); expect(onChange).toHaveBeenCalledWith(['child1']); @@ -437,13 +449,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const onChange = jest.fn(); const standaloneOption = flatOptions[3]; - handleCheckboxChange( - standaloneOption, - true, - ['child1'], + handleCheckboxChange({ + option: standaloneOption, + isChecked: true, + selectedValues: ['child1'], flatOptions, - onChange - ); + onChange, + }); expect(onChange).toHaveBeenCalledWith(['child1', 'standalone']); }); @@ -465,16 +477,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should render a checked checkbox with correct props', () => { const state = { checked: true }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - true, // isRequired - false, // isDisabled - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: true, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -488,16 +500,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should render an indeterminate checkbox with correct props', () => { const state = { checked: false, indeterminate: true }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - false, // isRequired - false, // isDisabled - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -510,16 +522,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should render an unchecked checkbox with correct props', () => { const state = { checked: false }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - false, // isRequired - false, // isDisabled - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -532,16 +544,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should apply correct margin based on level', () => { const state = { checked: false }; - const result = renderCheckbox( - { ...mockOption, level: 2 }, + const result = renderCheckbox({ + option: { ...mockOption, level: 2 }, state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const listItem = container.querySelector('li'); @@ -552,16 +564,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should handle disabled state', () => { const state = { checked: false }; - const result = renderCheckbox( - { ...mockOption, disabled: true }, + const result = renderCheckbox({ + option: { ...mockOption, disabled: true }, state, - 'test-id', - false, - true, // isDisabled - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: true, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -572,17 +584,17 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should handle error state', () => { const state = { checked: false }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef, - true // error - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + error: true, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -597,16 +609,16 @@ describe('ConnectedNestedCheckboxes utils', () => { 'aria-label': 'Custom aria label', }; - const result = renderCheckbox( - optionWithAriaLabel, + const result = renderCheckbox({ + option: optionWithAriaLabel, state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -617,16 +629,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should fallback to label text for aria-label when label is string', () => { const state = { checked: false }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -641,16 +653,16 @@ describe('ConnectedNestedCheckboxes utils', () => { label: Element Label, }; - const result = renderCheckbox( - optionWithElementLabel as any, // ts should prevent this from ever happening but we have a default just in case + const result = renderCheckbox({ + option: optionWithElementLabel as any, // ts should prevent this from ever happening but we have a default just in case state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index 187a406fa33..06c2c1ee4d8 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -46,7 +46,6 @@ export const ConnectedNestedCheckboxes: React.FC< ); setValue(name, expandedValues); - onUpdate?.(expandedValues); // do we want to do this? setHasExpandedInitially(true); }, [ hasExpandedInitially, @@ -67,25 +66,25 @@ export const ConnectedNestedCheckboxes: React.FC< {flatOptions.map((option) => { const state = states.get(option.value)!; - return renderCheckbox( + return renderCheckbox({ option, state, - `${name}-${option.value}`, + checkboxId: `${name}-${option.value}`, isRequired, isDisabled, onBlur, - (event) => { - handleCheckboxChange( + onChange: (event) => { + handleCheckboxChange({ option, - event.target.checked, - value, + isChecked: event.target.checked, + selectedValues: value, flatOptions, onChange, - onUpdate - ); + onUpdate, + }); }, - ref - ); + ref, + }); })} ); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx index 2b3b4aa96ba..af6017f5314 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -97,14 +97,23 @@ export const calculateStates = ( return states; }; -export const handleCheckboxChange = ( - option: FlatCheckbox, - isChecked: boolean, - selectedValues: string[], - flatOptions: FlatCheckbox[], - onChange: (values: string[]) => void, - onUpdate?: (values: string[]) => void -) => { +interface HandleCheckboxChangeParams { + option: FlatCheckbox; + isChecked: boolean; + selectedValues: string[]; + flatOptions: FlatCheckbox[]; + onChange: (values: string[]) => void; + onUpdate?: (values: string[]) => void; +} + +export const handleCheckboxChange = ({ + option, + isChecked, + selectedValues, + flatOptions, + onChange, + onUpdate, +}: HandleCheckboxChangeParams) => { const currentValue = option.value; let newSelectedValues = [...selectedValues]; @@ -142,17 +151,29 @@ export const handleCheckboxChange = ( onUpdate?.(newSelectedValues); }; -export const renderCheckbox = ( - option: FlatCheckbox, - state: FlatCheckboxState, - checkboxId: string, - isRequired: boolean, - isDisabled: boolean, - onBlur: () => void, - onChange: (event: React.ChangeEvent) => void, - ref: React.RefCallback, - error?: boolean -) => { +interface RenderCheckboxParams { + option: FlatCheckbox; + state: FlatCheckboxState; + checkboxId: string; + isRequired: boolean; + isDisabled: boolean; + onBlur: () => void; + onChange: (event: React.ChangeEvent) => void; + ref: React.RefCallback; + error?: boolean; +} + +export const renderCheckbox = ({ + option, + state, + checkboxId, + isRequired, + isDisabled, + onBlur, + onChange, + ref, + error, +}: RenderCheckboxParams) => { let checkedProps = {}; if (state.checked) { checkedProps = { diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx index 7963a725886..7c92c4bd4b7 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -39,6 +39,7 @@ const mockOptions: NestedGridFormCheckboxOption[] = [ ]; const mockOnUpdate = jest.fn(); +const mockOnSubmit = jest.fn(); const TestForm: React.FC<{ defaultValue?: string[]; disabled?: boolean; @@ -57,7 +58,7 @@ const TestForm: React.FC<{ defaultValue, disabled, customError, - validation: { required: 'Please check the box to agree to the terms.' }, + validation: { required: 'Please check at least one option' }, }, ]} submit={{ @@ -65,7 +66,7 @@ const TestForm: React.FC<{ size: 4, }} validation="onSubmit" - onSubmit={jest.fn()} + onSubmit={mockOnSubmit} /> ); @@ -198,15 +199,6 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); expect(view.getByLabelText('React')).not.toBeChecked(); expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - - expect(mockOnUpdate).toHaveBeenCalledWith([ - 'backend', - 'node', - 'express', - 'fastify', - 'python', - 'java', - ]); }); it('should handle multiple parent defaults correctly', async () => { @@ -482,6 +474,38 @@ describe('GridFormNestedCheckboxInput', () => { expect(checkbox).toHaveAttribute('aria-invalid', 'true'); }); }); + + it('should submit successfully when validation passes', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + const submitButton = view.getByRole('button'); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(mockOnSubmit).toHaveBeenCalledWith( + { technologies: ['react'] }, + expect.any(Object) + ); + }); + + it('should show validation errors and not submit when validation fails', async () => { + const { view } = renderView(); + + const submitButton = view.getByRole('button'); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + view.getByText('Please check at least one option'); + }); }); describe('accessibility', () => { @@ -508,13 +532,12 @@ describe('GridFormNestedCheckboxInput', () => { const { view } = renderView(); const list = view.container.querySelector('ul'); - const listItems = view.container.querySelectorAll('li'); - expect(list).toBeInTheDocument(); - expect(listItems).toHaveLength(11); // Total flattened options - listItems.forEach((item) => { - expect(item).toHaveStyle({ listStyle: 'none' }); + expect(list?.children).toHaveLength(11); // Total flattened options + Array.from(list?.children || []).forEach((item) => { + // each child of the ul should be an li + expect(item).toBeInstanceOf(HTMLLIElement); }); }); }); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index aab0adf07dc..efd254417d3 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -49,7 +49,6 @@ export const GridFormNestedCheckboxInput: React.FC< ); setValue(field.name, expandedValues); - field.onUpdate?.(expandedValues); // do we want to do this? setHasExpandedInitially(true); }, [hasExpandedInitially, field, flatOptions, setValue]); @@ -62,26 +61,26 @@ export const GridFormNestedCheckboxInput: React.FC< {flatOptions.map((option) => { const state = states.get(option.value)!; - return renderCheckbox( + return renderCheckbox({ option, state, - `${field.name}-${option.value}`, - !!required, - !!isDisabled, + checkboxId: `${field.name}-${option.value}`, + isRequired: !!required, + isDisabled: !!isDisabled, onBlur, - (event) => { - handleCheckboxChange( + onChange: (event) => { + handleCheckboxChange({ option, - event.target.checked, - value, + isChecked: event.target.checked, + selectedValues: value, flatOptions, onChange, - field.onUpdate - ); + onUpdate: field.onUpdate, + }); }, ref, - error - ); + error, + }); })} ); From b55a426c619a6e81d46f6e552cf979966e8e2ecd Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 14 Oct 2025 17:15:18 -0400 Subject: [PATCH 23/25] improvements --- .../ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx | 2 ++ .../ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx | 2 +- .../styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) 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 43e9fe0bdb0..3b2395c747c 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -59,6 +59,8 @@ export const Default = () => { minHeight="50rem" onSubmit={(values) => { action('Form Submitted')(values); + // eslint-disable-next-line no-console + console.log('Form Submitted', values); }} {...connectedFormProps} > diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index 6ae3c4a2189..4f7dbf97fa3 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -36,7 +36,7 @@ For further styling configurations, check out