diff --git a/packages/forms/docs/components/MultiInput.story.mdx b/packages/forms/docs/components/MultiInput.story.mdx
new file mode 100644
index 00000000..21682e94
--- /dev/null
+++ b/packages/forms/docs/components/MultiInput.story.mdx
@@ -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';
+
+
+
+# Overview
+
+`MultiInput` is an component that allows you to enter many input.
+
+### This is a basic example.
+
+
+ {stories.DefaultMultiInput()}
+
+
+# Component props
+
+
diff --git a/packages/forms/docs/components/MultiPhoneNumberInput.story.mdx b/packages/forms/docs/components/MultiPhoneNumberInput.story.mdx
index 4cf4dc7b..a3293273 100644
--- a/packages/forms/docs/components/MultiPhoneNumberInput.story.mdx
+++ b/packages/forms/docs/components/MultiPhoneNumberInput.story.mdx
@@ -11,7 +11,7 @@ import * as stories from '../stories/MultiPhoneNumberInput.story.js';
### This is a basic example.
- {stories.BasicMultiPhoneNumberInput()}
+ {stories.DefaultMultiPhoneNumberInput()}
# Component props
diff --git a/packages/forms/docs/stories/MultiInput.story.js b/packages/forms/docs/stories/MultiInput.story.js
new file mode 100644
index 00000000..89ba8602
--- /dev/null
+++ b/packages/forms/docs/stories/MultiInput.story.js
@@ -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;
+
+export const DefaultMultiInput = () => {
+ const [value, setValue] = useState();
+ return (
+
+
+
+
+ );
+};
+
+export const RemoveNoteInput = () => {
+ const [value, setValue] = useState();
+ const onAdd = (index) => {
+ return [{ label: `Patient #${index + 1}` }];
+ };
+ return (
+
+
+
+
+ );
+};
+
+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 (
+
+ (
+
+ )}
+ />
+
+
+ );
+};
+
+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 (
+
+
+ }
+ validate={validate}
+ />
+
+
+
+ );
+};
+
+export default {
+ title: 'Modules/Forms/Stories/MultiInput',
+ parameters: {
+ viewOnGithub: {
+ fileName: __filename,
+ },
+ },
+};
diff --git a/packages/forms/src/components/MultiInput/__tests__/index.spec.js b/packages/forms/src/components/MultiInput/__tests__/index.spec.js
new file mode 100644
index 00000000..bfd0aa6a
--- /dev/null
+++ b/packages/forms/src/components/MultiInput/__tests__/index.spec.js
@@ -0,0 +1,101 @@
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+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();
+ });
+
+ it('renders the label', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(Label).text()).toEqual('Test Label');
+ });
+
+ it('adds an input when add button is clicked', () => {
+ const wrapper = shallow();
+ 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(
+ ,
+ );
+ 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();
+ 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(
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+ const noteInput = wrapper.find('input').at(1);
+
+ noteInput.simulate('change', { target: { value: 'updated note' } });
+
+ expect(onChangeMock).toHaveBeenCalledWith([['', 'updated note']]);
+ });
+});
diff --git a/packages/forms/src/components/MultiInput/index.tsx b/packages/forms/src/components/MultiInput/index.tsx
new file mode 100644
index 00000000..45bca4df
--- /dev/null
+++ b/packages/forms/src/components/MultiInput/index.tsx
@@ -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 {
+ label?: ReactNode;
+ error?: ReactNode;
+ value?: T;
+ onChange?: (value: any) => void;
+}
+
+interface MultiInputProps {
+ label: string;
+ component?: ComponentType>;
+ value?: Array<[T | undefined, string | undefined]>;
+ onChange?: (value: Array<[T | undefined, string | undefined]>) => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ error?: string | Record;
+ max?: number;
+ onAdd?: (index: number) => [InputConfig, InputConfig] | [InputConfig];
+}
+
+function MultiInput(props: MultiInputProps): 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) => {
+ 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;
+ const noteError = rowError && rowError.note;
+ const showNote = inputConfig[1];
+
+ return (
+ // eslint-disable-next-line react/no-array-index-key
+
+ );
+ });
+
+ return (
+
+
+ {inputs}
+
+ {error}
+
+
+
+
+
+ );
+}
+
+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;
diff --git a/packages/forms/src/components/MultiInput/styled.ts b/packages/forms/src/components/MultiInput/styled.ts
new file mode 100644
index 00000000..2e54aedf
--- /dev/null
+++ b/packages/forms/src/components/MultiInput/styled.ts
@@ -0,0 +1,42 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import { Input, ButtonIcon } from 'react-rainbow-components';
+import styled from 'styled-components';
+
+export const Container = styled.div``;
+
+export const Label = styled.label`
+ font-size: 18px;
+ color: ${(props) => props.theme.rainbow.palette.text.main};
+ margin-bottom: 4px;
+`;
+
+export const Fieldset = styled.fieldset`
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 16px;
+`;
+
+export const StyledInput = styled(Input)`
+ min-width: 65%;
+ flex: 1 1 auto;
+`;
+
+export const StyledNoteInput = styled(Input)`
+ flex-grow: 1;
+`;
+
+export const ErrorText = styled.div`
+ font-size: 0.875rem;
+ margin-top: 0.5rem;
+ align-self: start;
+ color: ${(props) => props.theme.rainbow.palette.error.main};
+`;
+
+export const StyledButtonIcon = styled(ButtonIcon)`
+ flex-shrink: 0;
+ margin-top: 24px;
+`;
diff --git a/packages/forms/src/components/MultiInput/types.ts b/packages/forms/src/components/MultiInput/types.ts
new file mode 100644
index 00000000..8956487b
--- /dev/null
+++ b/packages/forms/src/components/MultiInput/types.ts
@@ -0,0 +1,12 @@
+export interface InputConfig {
+ label: string;
+ placeholder?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+}
+
+export interface RowError {
+ value?: string;
+ note?: string;
+}