From 44649cb41b2a14b26c809f6e6f9599a596c55efb Mon Sep 17 00:00:00 2001 From: kumikokashii Date: Tue, 9 Jun 2020 21:00:59 -0700 Subject: [PATCH 01/23] feat(patient): allow multiple entries for contact info --- .../enUs/translations/actions/index.ts | 1 + .../enUs/translations/patient/index.ts | 10 + src/model/ContactInformation.ts | 13 +- src/patients/ContactInfo.tsx | 190 +++++++++++++++++ src/patients/ContactInfoTypes.ts | 9 + src/patients/GeneralInformation.tsx | 200 +++++++++--------- src/patients/edit/EditPatient.tsx | 38 ++-- src/patients/new/NewPatient.tsx | 35 +-- src/patients/patient-slice.ts | 34 ++- 9 files changed, 384 insertions(+), 146 deletions(-) create mode 100644 src/patients/ContactInfo.tsx create mode 100644 src/patients/ContactInfoTypes.ts diff --git a/src/locales/enUs/translations/actions/index.ts b/src/locales/enUs/translations/actions/index.ts index 9a02a66f0e..58113ee8d0 100644 --- a/src/locales/enUs/translations/actions/index.ts +++ b/src/locales/enUs/translations/actions/index.ts @@ -14,5 +14,6 @@ export default { next: 'Next', previous: 'Previous', page: 'Page', + add: 'Add', }, } diff --git a/src/locales/enUs/translations/patient/index.ts b/src/locales/enUs/translations/patient/index.ts index dc46024ba2..0ed83fb712 100644 --- a/src/locales/enUs/translations/patient/index.ts +++ b/src/locales/enUs/translations/patient/index.ts @@ -13,6 +13,16 @@ export default { approximateAge: 'Approximate Age', placeOfBirth: 'Place of Birth', sex: 'Sex', + contactInfoType: { + label: 'Type', + options: { + home: 'Home', + mobile: 'Mobile', + work: 'Work', + temporary: 'Temporary', + old: 'Old', + }, + }, phoneNumber: 'Phone Number', email: 'Email', address: 'Address', diff --git a/src/model/ContactInformation.ts b/src/model/ContactInformation.ts index 96a494861d..da06be5f05 100644 --- a/src/model/ContactInformation.ts +++ b/src/model/ContactInformation.ts @@ -1,5 +1,10 @@ -export default interface ContactInformation { - phoneNumber: string - email?: string - address?: string +type ContactInfoPiece = { value: string; type?: string } + +interface ContactInformation { + phoneNumbers: ContactInfoPiece[] + emails?: ContactInfoPiece[] + addresses?: ContactInfoPiece[] } + +export default ContactInformation +export type { ContactInfoPiece } diff --git a/src/patients/ContactInfo.tsx b/src/patients/ContactInfo.tsx new file mode 100644 index 0000000000..46b6461389 --- /dev/null +++ b/src/patients/ContactInfo.tsx @@ -0,0 +1,190 @@ +import { Spinner, Row, Column, Icon } from '@hospitalrun/components' +import React, { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +import SelectWithLabelFormGroup from '../components/input/SelectWithLableFormGroup' +import TextFieldWithLabelFormGroup from '../components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../components/input/TextInputWithLabelFormGroup' +import { ContactInfoPiece } from '../model/ContactInformation' +import ContactInfoTypes from './ContactInfoTypes' + +interface Props { + data: ContactInfoPiece[] + errors?: string[] + label: string + name: string + isEditable?: boolean + onChange?: (newData: ContactInfoPiece[]) => void + type: 'text' | 'email' | 'tel' + isValid?: (entry: string) => boolean + errorLabel?: string +} + +const ContactInfo = (props: Props) => { + const { data, errors, label, name, isEditable, onChange, type, isValid, errorLabel } = props + const [tempErrors, setTempErrors] = useState() + + const { t } = useTranslation() + + const addEmpty = () => { + if (onChange) { + onChange([...data, { value: '' }]) + } + } + + useEffect(() => { + if (data.length === 0) { + addEmpty() + } + }, [data]) + + const getError = (i: number) => { + const tempError = tempErrors ? tempErrors[i] : null + if (tempError) { + return t(tempError) + } + + const error = errors ? errors[i] : null + if (error) { + return t(error) + } + + return '' + } + + const typeLabel = t('patient.contactInfoType.label') + const typeOptions = Object.values(ContactInfoTypes).map((value) => ({ + label: t(`patient.contactInfoType.options.${value}`), + value: `${value}`, + })) + + const addLabel = t('actions.add') + + const onChangeValue = (event: any, prevValue: string) => { + if (onChange) { + // eslint-disable-next-line no-shadow + const newData = data.map(({ value, type }) => + value === prevValue ? { value: event.currentTarget.value, type } : { value, type }, + ) + onChange(newData) + } + } + + // todo: acts strange when deleting empty rows above non-empty rows. + const getEntries = () => + data.map((entry, i) => { + const error = getError(i) + return ( + + + { + if (onChange) { + // eslint-disable-next-line no-shadow + const newData = data.map(({ value, type }) => + value === entry.value + ? { value, type: event.currentTarget.value } + : { value, type }, + ) + onChange(newData) + } + }} + /> + + + {['tel', 'email'].indexOf(type) > -1 ? ( + { + onChangeValue(event, entry.value) + }} + feedback={error} + isInvalid={!!error} + type={type} + /> + ) : ( + { + onChangeValue(event, entry.value) + }} + feedback={error} + isInvalid={!!error} + /> + )} + + + ) + }) + + const onClickAdd = () => { + const newData: ContactInfoPiece[] = [] + // eslint-disable-next-line no-underscore-dangle + const _tempErrors: string[] = [] + let hasError = false + + data.forEach((entry) => { + const value = entry.value.trim() + if (value !== '') { + newData.push(entry) + if (isValid) { + if (value === '') { + _tempErrors.push('') + } else if (!isValid(value)) { + _tempErrors.push(errorLabel || 'x') + hasError = true + } else { + _tempErrors.push('') + } + } + } + }) + + // update temp errors + setTempErrors(_tempErrors) + + // add new one if all good + if (!hasError) { + if (newData.length !== 0) { + if (newData[newData.length - 1].value !== '') { + newData.push({ value: '' }) + } + } + } + + // send data upward + if (onChange) { + onChange(newData) + } + } + + const addButton = ( +
+ +
+ ) + + return !data ? ( + + ) : ( +
+ {getEntries()} + {isEditable ? addButton : null} +
+ ) +} + +export default ContactInfo diff --git a/src/patients/ContactInfoTypes.ts b/src/patients/ContactInfoTypes.ts new file mode 100644 index 0000000000..7a10cb9048 --- /dev/null +++ b/src/patients/ContactInfoTypes.ts @@ -0,0 +1,9 @@ +enum ContactInfoTypes { + home = 'home', + mobile = 'mobile', + work = 'work', + temporary = 'temporary', + old = 'old', +} + +export default ContactInfoTypes diff --git a/src/patients/GeneralInformation.tsx b/src/patients/GeneralInformation.tsx index 94cdcede91..27ec80f47f 100644 --- a/src/patients/GeneralInformation.tsx +++ b/src/patients/GeneralInformation.tsx @@ -2,48 +2,52 @@ import { Panel, Checkbox, Alert } from '@hospitalrun/components' import { startOfDay, subYears, differenceInYears } from 'date-fns' import React from 'react' import { useTranslation } from 'react-i18next' +import validator from 'validator' import DatePickerWithLabelFormGroup from '../components/input/DatePickerWithLabelFormGroup' import SelectWithLabelFormGroup from '../components/input/SelectWithLableFormGroup' -import TextFieldWithLabelFormGroup from '../components/input/TextFieldWithLabelFormGroup' import TextInputWithLabelFormGroup from '../components/input/TextInputWithLabelFormGroup' +import { ContactInfoPiece } from '../model/ContactInformation' import Patient from '../model/Patient' +import ContactInfo from './ContactInfo' + +interface Error { + message?: string + prefix?: string + givenName?: string + familyName?: string + suffix?: string + dateOfBirth?: string + preferredLanguage?: string + phoneNumbers?: string[] + emails?: string[] +} interface Props { patient: Patient isEditable?: boolean - onFieldChange?: (key: string, value: string | boolean) => void - error?: any + onChange?: (newPatient: Partial) => void + error?: Error } const GeneralInformation = (props: Props) => { const { t } = useTranslation() - const { patient, isEditable, onFieldChange, error } = props - - const onSelectChange = (event: React.ChangeEvent, fieldName: string) => - onFieldChange && onFieldChange(fieldName, event.target.value) - - const onDateOfBirthChange = (date: Date) => - onFieldChange && onFieldChange('dateOfBirth', date.toISOString()) + const { patient, isEditable, onChange, error } = props - const onInputElementChange = (event: React.ChangeEvent, fieldName: string) => - onFieldChange && onFieldChange(fieldName, event.target.value) - - const onCheckboxChange = (event: React.ChangeEvent, fieldName: string) => - onFieldChange && onFieldChange(fieldName, event.target.checked) - - const onApproximateAgeChange = (event: React.ChangeEvent) => { - let approximateAgeNumber - if (Number.isNaN(parseFloat(event.target.value))) { - approximateAgeNumber = 0 - } else { - approximateAgeNumber = parseFloat(event.target.value) + const onFieldChange = (name: string, value: string | ContactInfoPiece[]) => { + if (onChange) { + const newPatient = { + ...patient, + [name]: value, + } + onChange(newPatient) } + } - const approximateDateOfBirth = subYears(new Date(Date.now()), approximateAgeNumber) - if (onFieldChange) { - onFieldChange('dateOfBirth', startOfDay(approximateDateOfBirth).toISOString()) - } + const guessDOBfromApproximateAge = (value: string) => { + const age = Number.isNaN(parseFloat(value)) ? 0 : parseFloat(value) + const dob = subYears(new Date(Date.now()), age) + return startOfDay(dob).toISOString() } return ( @@ -57,11 +61,9 @@ const GeneralInformation = (props: Props) => { name="prefix" value={patient.prefix} isEditable={isEditable} - onChange={(event: React.ChangeEvent) => { - onInputElementChange(event, 'prefix') - }} - isInvalid={error?.prefix} - feedback={t(error?.prefix)} + onChange={(event) => onFieldChange('prefix', event.currentTarget.value)} + isInvalid={!!error?.prefix} + feedback={error ? (error.prefix ? t(error.prefix) : undefined) : undefined} />
@@ -70,12 +72,10 @@ const GeneralInformation = (props: Props) => { name="givenName" value={patient.givenName} isEditable={isEditable} - onChange={(event: React.ChangeEvent) => { - onInputElementChange(event, 'givenName') - }} + onChange={(event) => onFieldChange('givenName', event.currentTarget.value)} isRequired - isInvalid={error?.givenName} - feedback={t(error?.givenName)} + isInvalid={!!error?.givenName} + feedback={error ? (error.givenName ? t(error.givenName) : undefined) : undefined} />
@@ -84,11 +84,9 @@ const GeneralInformation = (props: Props) => { name="familyName" value={patient.familyName} isEditable={isEditable} - onChange={(event: React.ChangeEvent) => { - onInputElementChange(event, 'familyName') - }} - isInvalid={error?.familyName} - feedback={t(error?.familyName)} + onChange={(event) => onFieldChange('familyName', event.currentTarget.value)} + isInvalid={!!error?.familyName} + feedback={error ? (error.familyName ? t(error.familyName) : undefined) : undefined} />
@@ -97,11 +95,9 @@ const GeneralInformation = (props: Props) => { name="suffix" value={patient.suffix} isEditable={isEditable} - onChange={(event: React.ChangeEvent) => { - onInputElementChange(event, 'suffix') - }} - isInvalid={error?.suffix} - feedback={t(error?.suffix)} + onChange={(event) => onFieldChange('suffix', event.currentTarget.value)} + isInvalid={!!error?.suffix} + feedback={error ? (error.suffix ? t(error.suffix) : undefined) : undefined} />
@@ -118,9 +114,7 @@ const GeneralInformation = (props: Props) => { { label: t('sex.other'), value: 'other' }, { label: t('sex.unknown'), value: 'unknown' }, ]} - onChange={(event: React.ChangeEvent) => { - onSelectChange(event, 'sex') - }} + onChange={(event) => onFieldChange('sex', event.currentTarget.value)} />
@@ -133,9 +127,7 @@ const GeneralInformation = (props: Props) => { { label: t('patient.types.charity'), value: 'charity' }, { label: t('patient.types.private'), value: 'private' }, ]} - onChange={(event: React.ChangeEvent) => { - onSelectChange(event, 'type') - }} + onChange={(event) => onFieldChange('type', event.currentTarget.value)} />
@@ -148,7 +140,14 @@ const GeneralInformation = (props: Props) => { type="number" value={`${differenceInYears(new Date(Date.now()), new Date(patient.dateOfBirth))}`} isEditable={isEditable} - onChange={onApproximateAgeChange} + onChange={ + (event) => + onFieldChange( + 'dateOfBirth', + guessDOBfromApproximateAge(event.currentTarget.value), + ) + // eslint-disable-next-line react/jsx-curly-newline + } /> ) : ( { ? new Date(patient.dateOfBirth) : undefined } - isInvalid={error?.dateOfBirth} maxDate={new Date(Date.now().valueOf())} - feedback={t(error?.dateOfBirth)} - onChange={(date: Date) => { - onDateOfBirthChange(date) - }} + onChange={(date: Date) => onFieldChange('dateOfBirth', date.toISOString())} + isInvalid={!!error?.dateOfBirth} + feedback={ + error ? (error.dateOfBirth ? t(error.dateOfBirth) : undefined) : undefined + } /> )} @@ -175,7 +174,10 @@ const GeneralInformation = (props: Props) => { label={t('patient.unknownDateOfBirth')} name="unknown" disabled={!isEditable} - onChange={(event) => onCheckboxChange(event, 'isApproximateDateOfBirth')} + onChange={ + (event) => onFieldChange('isApproximateDateOfBirth', event.currentTarget.value) + // eslint-disable-next-line react/jsx-curly-newline + } /> @@ -187,9 +189,7 @@ const GeneralInformation = (props: Props) => { name="occupation" value={patient.occupation} isEditable={isEditable} - onChange={(event: React.ChangeEvent) => { - onInputElementChange(event, 'occupation') - }} + onChange={(event) => onFieldChange('occupation', event.currentTarget.value)} />
@@ -198,62 +198,62 @@ const GeneralInformation = (props: Props) => { name="preferredLanguage" value={patient.preferredLanguage} isEditable={isEditable} - onChange={(event: React.ChangeEvent) => { - onInputElementChange(event, 'preferredLanguage') - }} - isInvalid={error?.preferredLanguage} - feedback={t(error?.preferredLanguage)} + onChange={(event) => onFieldChange('preferredLanguage', event.currentTarget.value)} + isInvalid={!!error?.preferredLanguage} + feedback={ + error + ? error.preferredLanguage + ? t(error.preferredLanguage) + : undefined + : undefined + } />

-
-
- + + ) => { - onInputElementChange(event, 'phoneNumber') - }} - feedback={t(error?.phoneNumber)} - isInvalid={!!error?.phoneNumber} + onChange={(newPhoneNumbers) => onFieldChange('phoneNumbers', newPhoneNumbers)} type="tel" + isValid={validator.isMobilePhone} + errorLabel="patient.errors.invalidPhoneNumber" /> -
-
- +
+
+ + ) => { - onInputElementChange(event, 'email') - }} + onChange={(newEmails) => onFieldChange('emails', newEmails)} type="email" - feedback={t(error?.email)} - isInvalid={!!error?.email} + isValid={validator.isEmail} + errorLabel="patient.errors.invalidEmail" /> -
+
-
-
- + + ) => - onFieldChange && onFieldChange('address', event.currentTarget.value) - // eslint-disable-next-line react/jsx-curly-newline - } + onChange={(newAddresses) => onFieldChange('addresses', newAddresses)} + type="text" /> -
+
diff --git a/src/patients/edit/EditPatient.tsx b/src/patients/edit/EditPatient.tsx index 57babfa477..00f29e3e86 100644 --- a/src/patients/edit/EditPatient.tsx +++ b/src/patients/edit/EditPatient.tsx @@ -69,24 +69,26 @@ const EditPatient = () => { } const onSave = async () => { - await dispatch( - updatePatient( - { - ...patient, - fullName: getPatientName(patient.givenName, patient.familyName, patient.suffix), - index: - getPatientName(patient.givenName, patient.familyName, patient.suffix) + patient.code, - }, - onSuccessfulSave, - ), - ) - } + const { givenName, familyName, suffix, code, phoneNumbers, emails, addresses } = patient - const onFieldChange = (key: string, value: string | boolean) => { - setPatient({ + const newPatient = { ...patient, - [key]: value, - }) + fullName: getPatientName(givenName, familyName, suffix), + index: getPatientName(givenName, familyName, suffix) + code, + phoneNumbers: phoneNumbers.filter((p) => p.value.trim() !== ''), + } + if (emails) { + newPatient.emails = emails.filter((e) => e.value.trim() !== '') + } + if (addresses) { + newPatient.addresses = addresses.filter((a) => a.value.trim() !== '') + } + + await dispatch(updatePatient(newPatient, onSuccessfulSave)) + } + + const onPatientChange = (newPatient: Partial) => { + setPatient(newPatient as Patient) } if (status === 'loading') { @@ -96,9 +98,9 @@ const EditPatient = () => { return (
diff --git a/src/patients/new/NewPatient.tsx b/src/patients/new/NewPatient.tsx index b7b7c827ea..061cdd12af 100644 --- a/src/patients/new/NewPatient.tsx +++ b/src/patients/new/NewPatient.tsx @@ -42,30 +42,33 @@ const NewPatient = () => { } const onSave = () => { - dispatch( - createPatient( - { - ...patient, - fullName: getPatientName(patient.givenName, patient.familyName, patient.suffix), - }, - onSuccessfulSave, - ), - ) - } + const { givenName, familyName, suffix, phoneNumbers, emails, addresses } = patient - const onFieldChange = (key: string, value: string | boolean) => { - setPatient({ + const newPatient = { ...patient, - [key]: value, - }) + fullName: getPatientName(givenName, familyName, suffix), + phoneNumbers: phoneNumbers.filter((p) => p.value.trim() !== ''), + } + if (emails) { + newPatient.emails = emails.filter((e) => e.value.trim() !== '') + } + if (addresses) { + newPatient.addresses = addresses.filter((a) => a.value.trim() !== '') + } + + dispatch(createPatient(newPatient, onSuccessfulSave)) + } + + const onPatientChange = (newPatient: Partial) => { + setPatient(newPatient as Patient) } return (
diff --git a/src/patients/patient-slice.ts b/src/patients/patient-slice.ts index 2e98e75b89..60cc1116d9 100644 --- a/src/patients/patient-slice.ts +++ b/src/patients/patient-slice.ts @@ -35,8 +35,8 @@ interface Error { prefix?: string familyName?: string preferredLanguage?: string - email?: string - phoneNumber?: string + emails?: string[] + phoneNumbers?: string[] } interface AddRelatedPersonError { @@ -204,15 +204,33 @@ function validatePatient(patient: Patient) { } } - if (patient.email) { - if (!validator.isEmail(patient.email)) { - error.email = 'patient.errors.invalidEmail' + if (patient.emails) { + const errors: string[] = [] + patient.emails.forEach((email) => { + if (!validator.isEmail(email.value)) { + errors.push('patient.errors.invalidEmail') + } else { + errors.push('') + } + }) + // Only add to error obj if there's an error + if (errors.filter((value) => value === '').length !== patient.emails.length) { + error.emails = errors } } - if (patient.phoneNumber) { - if (!validator.isMobilePhone(patient.phoneNumber)) { - error.phoneNumber = 'patient.errors.invalidPhoneNumber' + if (patient.phoneNumbers) { + const errors: string[] = [] + patient.phoneNumbers.forEach((phoneNumber) => { + if (!validator.isMobilePhone(phoneNumber.value)) { + errors.push('patient.errors.invalidPhoneNumber') + } else { + errors.push('') + } + }) + // Only add to error obj if there's an error + if (errors.filter((value) => value === '').length !== patient.phoneNumbers.length) { + error.phoneNumbers = errors } } From dc0773da59182d618bb9727f29e8723ec7712da4 Mon Sep 17 00:00:00 2001 From: kumikokashii Date: Tue, 9 Jun 2020 22:08:57 -0700 Subject: [PATCH 02/23] fix(patient): fix build error --- src/patients/ContactInfo.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/patients/ContactInfo.tsx b/src/patients/ContactInfo.tsx index 46b6461389..5940621d37 100644 --- a/src/patients/ContactInfo.tsx +++ b/src/patients/ContactInfo.tsx @@ -26,17 +26,13 @@ const ContactInfo = (props: Props) => { const { t } = useTranslation() - const addEmpty = () => { - if (onChange) { - onChange([...data, { value: '' }]) - } - } - useEffect(() => { if (data.length === 0) { - addEmpty() + if (onChange) { + onChange([...data, { value: '' }]) + } } - }, [data]) + }, [data, onChange]) const getError = (i: number) => { const tempError = tempErrors ? tempErrors[i] : null From 5cf88bc33df1877e01b9ecfd19f6c345cde2a4a1 Mon Sep 17 00:00:00 2001 From: kumikokashii Date: Wed, 10 Jun 2020 11:01:05 -0700 Subject: [PATCH 03/23] feat(patient): cleanup --- .../input/SelectWithLableFormGroup.tsx | 4 +-- .../input/TextFieldWithLabelFormGroup.tsx | 4 +-- .../input/TextInputWithLabelFormGroup.tsx | 4 +-- src/model/ContactInformation.ts | 4 +-- src/patients/ContactInfo.tsx | 30 ++++++++++++++----- src/patients/GeneralInformation.tsx | 6 ++-- src/patients/edit/EditPatient.tsx | 8 ++--- src/patients/new/NewPatient.tsx | 8 ++--- 8 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/components/input/SelectWithLableFormGroup.tsx b/src/components/input/SelectWithLableFormGroup.tsx index fff067037c..c61cf415c7 100644 --- a/src/components/input/SelectWithLableFormGroup.tsx +++ b/src/components/input/SelectWithLableFormGroup.tsx @@ -8,7 +8,7 @@ interface Option { interface Props { value: string - label: string + label?: string name: string isRequired?: boolean isEditable?: boolean @@ -33,7 +33,7 @@ const SelectWithLabelFormGroup = (props: Props) => { const id = `${name}Select` return (
-