diff --git a/src/components/multi-json-input/__tests__/multi-json-input.test.tsx b/src/components/multi-json-input/__tests__/multi-json-input.test.tsx new file mode 100644 index 000000000..1abe72828 --- /dev/null +++ b/src/components/multi-json-input/__tests__/multi-json-input.test.tsx @@ -0,0 +1,219 @@ +import React from 'react'; + +import { render, screen, userEvent } from '@/test-utils/rtl'; + +import MultiJsonInput from '../multi-json-input'; +import type { Props } from '../multi-json-input.types'; + +describe('MultiJsonInput', () => { + const defaultProps = { + label: 'Test Label', + placeholder: 'Enter JSON', + value: [''], + onChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with default props', () => { + setup({}); + + expect(screen.getByText('Test Label')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter JSON')).toBeInTheDocument(); + expect(screen.getByText('Add')).toBeInTheDocument(); + }); + + it('renders with custom label and placeholder', () => { + setup({ + label: 'Custom Label', + placeholder: 'Custom Placeholder', + }); + + expect(screen.getByText('Custom Label')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Custom Placeholder') + ).toBeInTheDocument(); + }); + + it('renders multiple inputs when value array has multiple items', () => { + setup({ + value: ['{"key": "value1"}', '{"key": "value2"}'], + }); + + const textareas = screen.getAllByRole('textbox'); + expect(textareas).toHaveLength(2); + expect(textareas[0]).toHaveValue('{"key": "value1"}'); + expect(textareas[1]).toHaveValue('{"key": "value2"}'); + }); + + it('calls onChange when input value changes', async () => { + const onChange = jest.fn(); + const { user } = setup({ onChange }); + + const textarea = screen.getByPlaceholderText('Enter JSON'); + await user.type(textarea, 'test'); + + expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledTimes(4); // One call per character + }); + + it('enables add button only when all inputs have values', () => { + setup({ + value: ['{"key": "value1"}', '{"key": "value2"}'], + }); + + const addButton = screen.getByText('Add'); + expect(addButton).not.toBeDisabled(); + }); + + it('disables add button when any input is empty', () => { + setup({ + value: ['{"key": "value1"}', ''], + }); + + const addButton = screen.getByText('Add'); + expect(addButton).toBeDisabled(); + }); + + it('adds new input when add button is clicked', async () => { + const onChange = jest.fn(); + const { user } = setup({ + value: ['{"key": "value"}'], + onChange, + }); + + const addButton = screen.getByText('Add'); + await user.click(addButton); + + expect(onChange).toHaveBeenCalledWith(['{"key": "value"}', '']); + }); + + it('removes input when delete button is clicked and multiple inputs exist', async () => { + const onChange = jest.fn(); + const { user } = setup({ + value: ['{"key": "value1"}', '{"key": "value2"}'], + onChange, + }); + + const deleteButtons = screen.getAllByLabelText('Delete input'); + await user.click(deleteButtons[0]); + + expect(onChange).toHaveBeenCalledWith(['{"key": "value2"}']); + }); + + it('clears input when delete button is clicked and only one input exists', async () => { + const onChange = jest.fn(); + const { user } = setup({ + value: ['{"key": "value"}'], + onChange, + }); + + const deleteButton = screen.getByLabelText('Clear input'); + await user.click(deleteButton); + + expect(onChange).toHaveBeenCalledWith(['']); + }); + + it('disables delete button when only one input exists and it is empty', () => { + setup({}); + + const deleteButton = screen.getByLabelText('Clear input'); + expect(deleteButton).toBeDisabled(); + }); + + it('enables delete button when only one input exists and it has content', () => { + const props = { + ...defaultProps, + value: ['{"key": "value"}'], + }; + + render(); + + const deleteButton = screen.getByLabelText('Clear input'); + expect(deleteButton).not.toBeDisabled(); + }); + + it('displays error message when error prop is provided', () => { + const props = { + ...defaultProps, + error: 'Invalid JSON format', + }; + + render(); + + expect(screen.getByText('Invalid JSON format')).toBeInTheDocument(); + }); + + it('applies error state to textarea when error prop is provided', () => { + const props = { + ...defaultProps, + error: 'Invalid JSON format', + }; + + render(); + + const textarea = screen.getByPlaceholderText('Enter JSON'); + expect(textarea).toHaveAttribute('aria-invalid', 'true'); + }); + + it('handles empty value array by defaulting to single empty input', () => { + const props = { + ...defaultProps, + value: [], + }; + + render(); + + const textareas = screen.getAllByRole('textbox'); + expect(textareas).toHaveLength(1); + expect(textareas[0]).toHaveValue(''); + }); + + it('renders with custom add button text', () => { + const props = { + ...defaultProps, + addButtonText: 'Add argument', + }; + + render(); + + expect(screen.getByText('Add argument')).toBeInTheDocument(); + }); + + it('maintains input order when deleting inputs', async () => { + const onChange = jest.fn(); + const { user } = setup({ + value: ['input1', 'input2', 'input3'], + onChange, + }); + + const deleteButtons = screen.getAllByLabelText('Delete input'); + await user.click(deleteButtons[1]); // Delete middle input + + expect(onChange).toHaveBeenCalledWith(['input1', 'input3']); + }); +}); + +const setup = ({ + label = 'Test Label', + placeholder = 'Enter JSON', + value = [''], + onChange = jest.fn(), + error, + addButtonText, +}: Partial) => { + const user = userEvent.setup(); + const result = render( + + ); + return { ...result, user }; +}; diff --git a/src/components/multi-json-input/multi-json-input.styles.ts b/src/components/multi-json-input/multi-json-input.styles.ts new file mode 100644 index 000000000..c4a2a1f03 --- /dev/null +++ b/src/components/multi-json-input/multi-json-input.styles.ts @@ -0,0 +1,69 @@ +import { type Theme } from 'baseui'; +import { type TextareaOverrides } from 'baseui/textarea'; +import { type StyleObject } from 'styletron-react'; + +import type { + StyletronCSSObject, + StyletronCSSObjectOf, +} from '@/hooks/use-styletron-classes'; + +export const overrides = { + jsonInput: { + Input: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + ...$theme.typography.MonoParagraphSmall, + '::placeholder': { + ...$theme.typography.ParagraphSmall, + }, + }), + }, + } satisfies TextareaOverrides, +}; + +const cssStylesObj = { + container: (theme) => ({ + display: 'flex', + flexDirection: 'column', + gap: '16px', + borderLeft: `2px solid ${theme.colors.borderOpaque}`, + paddingLeft: '16px', + }), + inputRow: { + display: 'flex', + gap: '8px', + alignItems: 'flex-start', + }, + inputContainer: { + flex: 1, + }, + buttonContainer: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + minWidth: '40px', + paddingTop: '4px', + }, + deleteButton: { + padding: '8px', + borderRadius: '8px', + }, + addButtonContainer: { + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'flex-start', + }, + addButton: { + padding: '6px 12px', + fontSize: '12px', + fontWeight: '500', + lineHeight: '16px', + }, + plusIcon: { + fontSize: '16px', + fontWeight: '500', + lineHeight: '1', + }, +} satisfies StyletronCSSObject; + +export const cssStyles: StyletronCSSObjectOf = + cssStylesObj; diff --git a/src/components/multi-json-input/multi-json-input.tsx b/src/components/multi-json-input/multi-json-input.tsx new file mode 100644 index 000000000..9f2d12f91 --- /dev/null +++ b/src/components/multi-json-input/multi-json-input.tsx @@ -0,0 +1,133 @@ +'use client'; +import React, { useCallback, useMemo } from 'react'; + +import { Button, SHAPE, SIZE } from 'baseui/button'; +import { FormControl } from 'baseui/form-control'; +import { Textarea } from 'baseui/textarea'; +import { MdDeleteOutline } from 'react-icons/md'; + +import useStyletronClasses from '@/hooks/use-styletron-classes'; + +import { cssStyles, overrides } from './multi-json-input.styles'; +import type { Props } from './multi-json-input.types'; + +export default function MultiJsonInput({ + label, + placeholder, + value = [''], + onChange, + error, + addButtonText = 'Add', +}: Props) { + const { cls } = useStyletronClasses(cssStyles); + + const getInputError = useCallback( + (index: number): boolean => { + if (!error) return false; + if (typeof error === 'string') return true; // Global error affects all + if (Array.isArray(error)) return Boolean(error[index]); + return false; + }, + [error] + ); + + const getGlobalErrorMessage = useCallback((): string | undefined => { + if (typeof error === 'string') return error; + return undefined; + }, [error]); + + const displayValue = useMemo(() => { + return value && value.length > 0 ? value : ['']; + }, [value]); + + const canAddMore = useMemo(() => { + return displayValue.every((item: string) => item.trim() !== ''); + }, [displayValue]); + + const handleInputChange = useCallback( + (index: number, newValue: string) => { + const newArray = [...displayValue]; + newArray[index] = newValue; + onChange(newArray); + }, + [displayValue, onChange] + ); + + const handleAddInput = useCallback(() => { + if (canAddMore) { + onChange([...displayValue, '']); + } + }, [canAddMore, displayValue, onChange]); + + const handleDeleteInput = useCallback( + (index: number) => { + if (displayValue.length === 1) { + // If only one input, clear it instead of deleting + onChange(['']); + } else { + // Remove the input at the specified index + const newArray = displayValue.filter( + (_: string, i: number) => i !== index + ); + onChange(newArray); + } + }, + [displayValue, onChange] + ); + + const isDeleteDisabled = useMemo(() => { + return displayValue.length === 1 && displayValue[0] === ''; + }, [displayValue]); + + return ( + +
+ {displayValue.map((inputValue: string, index: number) => ( +
+
+