Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add MultiInput component #693

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/forms/docs/components/MultiInput.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import MultiInput from '../../src/components/MultiInput';
import * as stories from '../stories/MultiInput.story.js';

<Meta title="Modules/Forms/Components/MultiInput" component={MultiInput} />

# Overview

`MultiInput` is an component that allows you to enter many input.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's have chat GPT to help with the documentation


### This is a basic example.

<Preview>
<Story name="MultiInput">{stories.DefaultMultiInput()}</Story>
</Preview>

# Component props

<Props of={MultiInput} />
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as stories from '../stories/MultiPhoneNumberInput.story.js';
### This is a basic example.

<Preview>
<Story name="MultiPhoneNumberInput">{stories.BasicMultiPhoneNumberInput()}</Story>
<Story name="MultiPhoneNumberInput">{stories.DefaultMultiPhoneNumberInput()}</Story>
</Preview>

# Component props
Expand Down
109 changes: 109 additions & 0 deletions packages/forms/docs/stories/MultiInput.story.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { useState } from 'react';
import { Application, Button, GoogleAddressLookup, PhoneInput } from 'react-rainbow-components';
import ReactJson from 'react-json-view';
import { Field, UniversalForm } from '@rainbow-modules/forms';
import MultiInput from '../../src/components/MultiInput';

const GOOGLE_MAPS_APIKEY = process.env.STORYBOOK_GOOGLE_MAPS_APIKEY;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked and we already have an env for this called STORYBOOK_GOOGLE_MAPS_API_KEY in circle CI then let's change it here


export const DefaultMultiInput = () => {
const [value, setValue] = useState();
return (
<Application>
<MultiInput label="Text" value={value} onChange={setValue} error="An error ocurred" />
<ReactJson src={value} />
</Application>
);
};

export const RemoveNoteInput = () => {
const [value, setValue] = useState();
const onAdd = (index) => {
return [{ label: `Patient #${index + 1}` }];
};
return (
<Application>
<MultiInput label="Patient List" value={value} onChange={setValue} onAdd={onAdd} />
<ReactJson src={value} />
</Application>
);
};

export const FormMultiAddressInput = () => {
const [value, setValue] = useState();
const onAdd = (index) => {
if (index === 0) {
return [{ label: 'Patient primary address', required: true }, { label: 'Note' }];
}
return [{ label: 'Patient family address' }, { label: 'Note' }];
};
return (
<Application>
<MultiInput
label="Addresses"
value={value}
onChange={setValue}
onAdd={onAdd}
component={(props) => (
<GoogleAddressLookup apiKey={GOOGLE_MAPS_APIKEY} {...props} />
)}
/>
<ReactJson src={value} />
</Application>
);
};

export const FormMultiPhoneNumberInput = () => {
const validate = (value) => {
if (value) {
if (value.length < 2) {
return 'Too few phone numbers';
}
const rowErrors = {};
value.forEach(([inputValue, note], index) => {
const err = {};
if (inputValue && inputValue.isoCode !== 'us') {
err.value = 'Only US numbers are accepted';
}
if (!note || note.length < 3) {
err.note = 'Note is too short';
}
if (Object.keys(err).length) {
rowErrors[index] = err;
}
});
if (Object.keys(rowErrors).length) {
return rowErrors;
}
}
return '';
};

const onSubmit = (values) => {
// eslint-disable-next-line no-alert
alert(JSON.stringify(values, null, 2));
};

return (
<Application>
<UniversalForm id="phones" onSubmit={onSubmit}>
<Field
name="phones"
label="Phone numbers"
component={(props) => <MultiInput component={PhoneInput} {...props} />}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's change the prop name component to inputComponent then we can use it like

<Field
    label="Phone numbers"
    component={MultiInput}
    inputComponent={PhoneInput}
    validate={validate}
/>

validate={validate}
/>
<Button type="submit" label="Submit" />
</UniversalForm>
</Application>
);
};

export default {
title: 'Modules/Forms/Stories/MultiInput',
parameters: {
viewOnGithub: {
fileName: __filename,
},
},
};
101 changes: 101 additions & 0 deletions packages/forms/src/components/MultiInput/__tests__/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React from 'react';
import { mount, shallow } from 'enzyme';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's not use enzyme anymore, instead use react testing library

import { Application, Button } from 'react-rainbow-components';
import MultiInput from '../index';
import { ErrorText, Label, StyledButtonIcon, StyledInput, StyledNoteInput } from '../styled';

describe('MultiInput component', () => {
const defaultProps = {
label: 'Test Label',
component: undefined,
value: undefined,
onChange: jest.fn(),
onFocus: jest.fn(),
onBlur: jest.fn(),
error: undefined,
max: 5,
onAdd: undefined,
};

it('renders without crashing', () => {
shallow(<MultiInput {...defaultProps} />);
});

it('renders the label', () => {
const wrapper = shallow(<MultiInput {...defaultProps} />);
expect(wrapper.find(Label).text()).toEqual('Test Label');
});

it('adds an input when add button is clicked', () => {
const wrapper = shallow(<MultiInput {...defaultProps} />);
const addButton = wrapper.find(Button);
addButton.simulate('click');
expect(defaultProps.onChange).toHaveBeenCalledWith([
[undefined, undefined],
[undefined, undefined],
]);
});

it('removes an input when remove button is clicked', () => {
const wrapper = shallow(
<MultiInput
{...defaultProps}
value={[
[undefined, undefined],
[undefined, undefined],
]}
/>,
);
const removeButton = wrapper.find(StyledButtonIcon).first();
removeButton.simulate('click');
expect(defaultProps.onChange).toHaveBeenCalledWith([[undefined, undefined]]);
});
it('displays error message when error prop is a string', () => {
const wrapper = shallow(<MultiInput {...defaultProps} error="Invalid phone number" />);
expect(wrapper.find(ErrorText).text()).toBe('Invalid phone number');
});
it('displays error messages for each input when error prop is an object', () => {
const error = {
0: { value: 'Invalid phone number', note: 'Note error' },
1: { value: 'Invalid phone number' },
};
const wrapper = shallow(
<MultiInput
{...defaultProps}
value={[
[undefined, ''],
[undefined, ''],
]}
error={error}
/>,
);
expect(wrapper.find(StyledInput).at(0).prop('error')).toBe('Invalid phone number');
expect(wrapper.find(StyledNoteInput).at(0).prop('error')).toBe('Note error');
expect(wrapper.find(StyledInput).at(1).prop('error')).toBe('Invalid phone number');
expect(wrapper.find(StyledNoteInput).at(1).prop('error')).toBeUndefined();
});
it('should call onChange prop when input value changes', () => {
const onChangeMock = jest.fn();
const wrapper = mount(
<Application>
<MultiInput label="test label" onChange={onChangeMock} />
</Application>,
);
const input = wrapper.find('input').first();
input.simulate('change', { target: { value: 'test value' } });
expect(onChangeMock).toHaveBeenCalledWith([['test value', undefined]]);
});
it('calls onChange with the updated note value when updateNote is called', () => {
const onChangeMock = jest.fn();
const wrapper = mount(
<Application>
<MultiInput value={[['', 'note']]} onChange={onChangeMock} />
</Application>,
);
const noteInput = wrapper.find('input').at(1);

noteInput.simulate('change', { target: { value: 'updated note' } });

expect(onChangeMock).toHaveBeenCalledWith([['', 'updated note']]);
});
});
152 changes: 152 additions & 0 deletions packages/forms/src/components/MultiInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/no-unused-prop-types */
/* eslint-disable @typescript-eslint/no-empty-function */
import React, { ComponentType, ReactNode } from 'react';
import PropTypes from 'prop-types';
import { Button, Input, RenderIf } from 'react-rainbow-components';
import { Close, Plus } from '@rainbow-modules/icons';
import { useReduxForm } from '@rainbow-modules/hooks';
import {
Container,
ErrorText,
Fieldset,
Label,
StyledInput,
StyledNoteInput,
StyledButtonIcon,
} from './styled';
import { InputConfig, RowError } from './types';

interface InputProps<T> {
label?: ReactNode;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think label is always required

error?: ReactNode;
value?: T;
onChange?: (value: any) => void;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we must avoid using any

}

interface MultiInputProps<T> {
label: string;
component?: ComponentType<InputProps<T>>;
value?: Array<[T | undefined, string | undefined]>;
onChange?: (value: Array<[T | undefined, string | undefined]>) => void;
onFocus?: () => void;
onBlur?: () => void;
error?: string | Record<number, RowError>;
max?: number;
onAdd?: (index: number) => [InputConfig, InputConfig] | [InputConfig];
}

function MultiInput<T>(props: MultiInputProps<T>): JSX.Element {
const {
label,
value: valueInProps,
error,
onAdd,
max = 5,
component,
onChange = () => {},
} = useReduxForm(props);
const Component = component || Input;
const value: Array<[T | undefined, string | undefined]> = Array.isArray(valueInProps)
? valueInProps
: [[undefined, undefined]];

const updatePhone = (index: number, newValue: any) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updatePhone?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no any instead you can use unkown

if (newValue?.target?.value) {
value[index][0] = newValue?.target?.value as T;
} else {
value[index][0] = newValue as T;
}
onChange([...value]);
};

const updateNote = (index: number, noteValue: string) => {
value[index][1] = noteValue;
onChange([...value]);
};

const addInput = () => {
onChange([...value, [undefined, undefined]]);
};

const removeInput = (index: number) => {
onChange(value.slice(0, index).concat(value.slice(index + 1, value.length)));
};

const inputs = value.map(([inputValue, note], index: number) => {
const isFirst = index === 0;
const inputConfig = onAdd
? onAdd(index)
: [{ label: isFirst ? 'Primary' : 'Secondary' }, { label: 'Note' }];

const rowError = !error || typeof error === 'string' ? null : (error[index] as RowError);
const phoneError = rowError && rowError.value;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change var name phoneError

const noteError = rowError && rowError.note;
const showNote = inputConfig[1];

return (
// eslint-disable-next-line react/no-array-index-key
<Fieldset key={`value-${index}`} data-key={`value-${index}`}>
<StyledInput
as={Component}
label={inputConfig[0].label}
onChange={(newValue: any) => updatePhone(index, newValue)}
value={inputValue}
error={phoneError}
/>
{showNote && (
<StyledNoteInput
label={inputConfig[1]?.label}
onChange={(event) => updateNote(index, event.target.value)}
value={note}
error={noteError}
/>
)}
<RenderIf isTrue={!isFirst}>
<StyledButtonIcon
icon={<Close />}
onClick={() => removeInput(index)}
variant="border-filled"
/>
</RenderIf>
</Fieldset>
);
});

return (
<Container>
<Label>{label}</Label>
{inputs}
<RenderIf isTrue={typeof error === 'string'}>
<ErrorText>{error}</ErrorText>
</RenderIf>
<RenderIf isTrue={value.length < max}>
<Button variant="base" onClick={addInput}>
<Plus className="rainbow-m-right_small" />
Add Another Input
</Button>
</RenderIf>
</Container>
);
}

MultiInput.propTypes = {
label: PropTypes.string.isRequired,
value: PropTypes.any,
onAdd: PropTypes.func,
max: PropTypes.number,
onChange: PropTypes.func,
};

MultiInput.defaultProps = {
value: undefined,
onAdd: undefined,
component: undefined,
max: 5,
onChange: () => {},
onFocus: () => {},
onBlur: () => {},
error: undefined,
};

export default MultiInput;
Loading