Skip to content

Commit

Permalink
Merge branch 'feature/InputList' into q/1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
bert-e committed Dec 19, 2023
2 parents 7c3c5d3 + ff085d8 commit bf3e837
Show file tree
Hide file tree
Showing 9 changed files with 400 additions and 6 deletions.
24 changes: 23 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
"dependencies": {
"@floating-ui/dom": "^0.1.10",
"@storybook/preview-api": "^7.5.3",
"framer-motion": "^4.1.17"
"framer-motion": "^4.1.17",
"react-hook-form": "^7.49.2"
}
}
2 changes: 1 addition & 1 deletion src/lib/components/buttonv2/CopyButton.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const CopyButton = ({
}
/>
}
disabled={copyStatus === COPY_STATE_SUCCESS}
disabled={copyStatus === COPY_STATE_SUCCESS || props.disabled}
onClick={() => copy(textToCopy)}
type="button"
tooltip={
Expand Down
111 changes: 111 additions & 0 deletions src/lib/components/inputlist/InputButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import styled, { CSSProperties } from 'styled-components';
import { Button } from '../buttonv2/Buttonv2.component';
import { useCallback, useMemo } from 'react';
import { Box } from '../box/Box';
import { Icon } from '../icon/Icon.component';

const CustomButton = styled(Button)<{ isVisible?: boolean }>`
${(props) =>
!props.isVisible
? `
display: none;
`
: ''}
`;
const isEmptyItem = (item) => item.key === '' && item.value === '';

type AddButtonProps<T = unknown> = {
index: number;
items: Array<T>;
insertEntry: () => void;
disabled?: boolean;
iconStyle?: CSSProperties;
};

export const AddButton = ({
index,
items,
insertEntry,
disabled,
iconStyle,
}: AddButtonProps) => {
const itemsLength = items.length;
const itemsIndex = items[index];
const itemsIndexKey = (items[index] as { key: string }).key;
const itemsIndexValue = (items[index] as { value: string }).value;

const isDisabled = useMemo(() => {
if (itemsIndex && itemsIndexKey === '' && itemsIndexValue === '') {
return true;
}
return disabled || false;
}, [itemsIndex, itemsIndexKey, itemsIndexValue, disabled]);

const isVisible = useMemo(() => {
return !(itemsLength > 0 && index !== itemsLength - 1);
}, [itemsLength, index]);

const onClickFn = useCallback(() => {
if (!(itemsLength > 0 && index !== itemsLength - 1)) {
insertEntry();
}
}, [itemsLength, index, insertEntry]);

return (
<>
{!isVisible && <Box ml={16} />}
<CustomButton
isVisible={isVisible}
type="button"
variant="outline"
disabled={isDisabled}
name={`addbtn${index}`}
id={`addbtn${index}`}
onClick={onClickFn}
aria-label={`Add${index}`}
tooltip={{
overlay: 'Add',
placement: 'top',
}}
icon={<Icon name="Add-plus" />}
/>
</>
);
};
type SubButtonProps<T = unknown> = {
index: number;
items: Array<T>;
deleteEntry: (arg0: number) => void;
disabled?: boolean;
iconStyle?: CSSProperties;
};
export const SubButton = ({
index,
items,
deleteEntry,
disabled,
iconStyle,
}: SubButtonProps) => {
let isDisabled = disabled || false;

if (items.length === 1 && isEmptyItem(items[0])) {
isDisabled = true;
}

return (
<Button
variant="danger"
type="button"
disabled={isDisabled}
aria-label={`Remove${index}`}
name={`delbtn${index}`}
id={`delbtn${index}`}
onClick={() => deleteEntry(index)}
tooltip={{
overlay: 'Remove',
placement: 'top',
}}
icon={<Icon name="Remove-minus" />}
/>
);
};
98 changes: 98 additions & 0 deletions src/lib/components/inputlist/InputList.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { HTMLProps, forwardRef } from 'react';
import { RefCallBack } from 'react-hook-form';
import { Box } from '../box/Box';
import { Input } from '../inputv2/inputv2';
import { AddButton, SubButton } from './InputButtons';

export type InputListProps<T> = HTMLProps<HTMLInputElement> & {
ref: RefCallBack;
min?: string | number;
max?: string | number;
maxLength?: number;
minLength?: number;
pattern?: string;
required?: boolean;
disabled?: boolean;
maxItems?: number;
value: T[];
};

function InternalInputList<
T extends string | number | readonly string[] | undefined,
>(
{
onChange,
onBlur,
min,
max,
maxLength,
minLength,
pattern,
required,
disabled,
maxItems,
value,
name,
...rest
}: InputListProps<T>,
_,
) {
const isMaxItemsReached =
maxItems !== undefined && maxItems !== null && value.length === maxItems;

const insertEntry = () => {
if (!isMaxItemsReached) {
onChange?.({
target: { value: [...(value ?? []), ''] },
} as unknown as React.ChangeEvent<HTMLInputElement>);
}
};

const deleteEntry = (entryIndex: number) => {
const newValues = value.filter((_, index) => index !== entryIndex);
const updatedValues = newValues.length === 0 ? ([''] as T[]) : newValues;

onChange?.({
target: { value: updatedValues },
} as unknown as React.ChangeEvent<HTMLInputElement>);
};

return (
<>
{value.map((val, index) => (
<Box display="flex" gap="0.25rem" alignItems="center" key={index}>
<Input
id={`${name}[${index}]`}
aria-label={`${name}${index}`}
inputSize={'1/2'}
value={val}
onChange={(evt) => {
const tempValues = [...value];
tempValues[index] = evt.target.value as T;
onChange?.({
target: { value: tempValues },
} as unknown as React.ChangeEvent<HTMLInputElement>);
}}
{...rest}
/>
<SubButton
index={index}
key={`${name}-delete-${value.join(',') + index}`}
deleteEntry={deleteEntry}
items={value}
disabled={value.length === 1 && value[0] === ''}
/>
<AddButton
index={index}
key={`${name}-add-${value.join(',') + index}`}
insertEntry={insertEntry}
items={value}
disabled={val === '' || isMaxItemsReached}
/>
</Box>
))}
</>
);
}

export const InputList = forwardRef(InternalInputList);
102 changes: 102 additions & 0 deletions src/lib/components/inputlist/InputList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { InputList, InputListProps } from './InputList.component';
import { FormSection } from '../form/Form.component';
import { QueryClient, QueryClientProvider } from 'react-query';

describe('InputList', () => {
const onChangeMock = jest.fn();

const renderInputList = (props: InputListProps<string[]>) => {
render(
<QueryClientProvider client={new QueryClient()}>
<FormSection>
<InputList
placeholder="Input list Test"
onChange={onChangeMock}
value={props.value}
name="inputListTest"
/>
</FormSection>
</QueryClientProvider>,
);
};

beforeEach(() => {
onChangeMock.mockClear();
});

it('should render an empty input list', () => {
renderInputList({
value: [''],
});

expect(screen.getByLabelText('inputListTest0')).toHaveValue('');
});

it('should render an input list with initial values', () => {
const initialValues = ['Value 1', 'Value 2', 'Value 3'];

renderInputList({
value: initialValues,
});

const inputElements = screen.getAllByRole('textbox');

expect(inputElements).toHaveLength(initialValues.length);

inputElements.forEach((input, index) => {
expect(input).toHaveValue(initialValues[index]);
});
});

it('should add a new input when clicking the add button', () => {
const initialValues = ['Value 1', 'Value 2'];

renderInputList({
value: initialValues,
});

const addButton = screen.getByLabelText('Add1');

fireEvent.click(addButton);

expect(onChangeMock).toHaveBeenCalledWith({
target: { value: [...initialValues, ''] },
});
});

it('should delete an input when clicking the delete button', () => {
const initialValues = ['Value 1', 'Value 2', 'Value 3'];

renderInputList({
value: initialValues,
});

const deleteButton = screen.getByLabelText('Remove1');

fireEvent.click(deleteButton);

expect(onChangeMock).toHaveBeenCalledWith({
target: { value: ['Value 1', 'Value 3'] },
});
});

it('should update the value of an input', () => {
const initialValues = ['Value 1', 'Value 2', 'Value 3'];

renderInputList({
value: initialValues,
});

const inputElements = screen.getAllByRole('textbox');

const newValue = 'New Value';

fireEvent.change(inputElements[1], { target: { value: newValue } });

expect(onChangeMock).toHaveBeenCalledWith({
target: { value: ['Value 1', newValue, 'Value 3'] },
});
});
});
Loading

0 comments on commit bf3e837

Please sign in to comment.