-
-
Notifications
You must be signed in to change notification settings - Fork 6
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. | ||
|
||
### 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 |
---|---|---|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} />} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's change the prop name
|
||
validate={validate} | ||
/> | ||
<Button type="submit" label="Submit" /> | ||
</UniversalForm> | ||
</Application> | ||
); | ||
}; | ||
|
||
export default { | ||
title: 'Modules/Forms/Stories/MultiInput', | ||
parameters: { | ||
viewOnGithub: { | ||
fileName: __filename, | ||
}, | ||
}, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import React from 'react'; | ||
import { mount, shallow } from 'enzyme'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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']]); | ||
}); | ||
}); |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. updatePhone? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; |
There was a problem hiding this comment.
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