diff --git a/apps/modernization-ui/src/apps/patient/add/basic/phoneEmail/BasicPhoneEmailFields.spec.tsx b/apps/modernization-ui/src/apps/patient/add/basic/phoneEmail/BasicPhoneEmailFields.spec.tsx index c3818fe45d..5b1338b142 100644 --- a/apps/modernization-ui/src/apps/patient/add/basic/phoneEmail/BasicPhoneEmailFields.spec.tsx +++ b/apps/modernization-ui/src/apps/patient/add/basic/phoneEmail/BasicPhoneEmailFields.spec.tsx @@ -72,4 +72,19 @@ describe('PhoneEmailEntryFields', () => { } }); }); + + it('should verify email field', async () => { + const { getByLabelText, getByText } = render(); + const email = getByLabelText('Email'); + + userEvent.paste(email, 'invalid-email'); + userEvent.tab(); + + await waitFor(() => { + const validationError = getByText( + 'Please enter Email as an email address (example: youremail@website.com).' + ); + expect(validationError).toBeInTheDocument(); + }); + }); }); diff --git a/apps/modernization-ui/src/apps/patient/add/basic/phoneEmail/BasicPhoneEmailFields.tsx b/apps/modernization-ui/src/apps/patient/add/basic/phoneEmail/BasicPhoneEmailFields.tsx index 6221f9bfc0..ee6fc768ce 100644 --- a/apps/modernization-ui/src/apps/patient/add/basic/phoneEmail/BasicPhoneEmailFields.tsx +++ b/apps/modernization-ui/src/apps/patient/add/basic/phoneEmail/BasicPhoneEmailFields.tsx @@ -2,7 +2,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import { EntryFieldsProps } from 'design-system/entry'; import { maxLengthRule } from 'validation/entry'; import { Verification } from 'libs/verification'; -import { EmailField, validateEmail, PhoneNumberInputField, validPhoneNumberRule } from 'libs/demographics/contact'; +import { EmailField, maybeValidateEmail, PhoneNumberInputField, validPhoneNumberRule } from 'libs/demographics/contact'; import { BasicPhoneEmail } from '../entry'; import { MaskedTextInputField } from 'design-system/input/text'; @@ -13,7 +13,10 @@ const EMAIL_LABEL = 'Email'; type BasicPhoneEmailFieldsProps = EntryFieldsProps; -export const BasicPhoneEmailFields = ({ orientation = 'horizontal' }: BasicPhoneEmailFieldsProps) => { +export const BasicPhoneEmailFields = ({ + orientation = 'horizontal', + sizing = 'compact' +}: BasicPhoneEmailFieldsProps) => { const { control } = useFormContext<{ phoneEmail: BasicPhoneEmail }>(); return ( @@ -25,12 +28,12 @@ export const BasicPhoneEmailFields = ({ orientation = 'horizontal' }: BasicPhone render={({ field: { onChange, onBlur, value, name }, fieldState: { error } }) => ( )} @@ -42,11 +45,11 @@ export const BasicPhoneEmailFields = ({ orientation = 'horizontal' }: BasicPhone render={({ field: { onChange, onBlur, value, name }, fieldState: { error } }) => ( @@ -69,9 +72,9 @@ export const BasicPhoneEmailFields = ({ orientation = 'horizontal' }: BasicPhone mask="____________________" pattern="^\+?\d{1,20}$" value={value} - sizing="compact" onBlur={onBlur} onChange={onChange} + sizing={sizing} orientation={orientation} error={error?.message} /> @@ -85,11 +88,11 @@ export const BasicPhoneEmailFields = ({ orientation = 'horizontal' }: BasicPhone render={({ field: { onChange, onBlur, value, name }, fieldState: { error } }) => ( @@ -102,17 +105,20 @@ export const BasicPhoneEmailFields = ({ orientation = 'horizontal' }: BasicPhone rules={maxLengthRule(100, EMAIL_LABEL)} render={({ field: { onChange, onBlur, value, name }, fieldState: { error } }) => ( ( { - verify(value); + verify(); onBlur(); }} onChange={onChange} value={value} + sizing={sizing} orientation={orientation} error={error?.message} warning={violation} diff --git a/apps/modernization-ui/src/apps/patient/add/contactFields/ContactFields.tsx b/apps/modernization-ui/src/apps/patient/add/contactFields/ContactFields.tsx index 2456859644..2d10e82a77 100644 --- a/apps/modernization-ui/src/apps/patient/add/contactFields/ContactFields.tsx +++ b/apps/modernization-ui/src/apps/patient/add/contactFields/ContactFields.tsx @@ -5,7 +5,7 @@ import FormCard from 'components/FormCard/FormCard'; import { Input } from 'components/FormInputs/Input'; import { maxLengthRule } from 'validation/entry'; import { Verification } from 'libs/verification'; -import { EmailField, validateEmail, PhoneNumberInputField, validPhoneNumberRule } from 'libs/demographics/contact'; +import { EmailField, maybeValidateEmail, PhoneNumberInputField, validPhoneNumberRule } from 'libs/demographics/contact'; const HOME_PHONE_LABEL = 'Home phone'; const WORK_PHONE_LABEL = 'Work phone'; @@ -123,13 +123,15 @@ export default function ContactFields({ id, title }: Props) { rules={maxLengthRule(100, EMAIL_LABEL)} render={({ field: { onChange, onBlur, value, name }, fieldState: { error } }) => ( ( { - verify(value); + verify(); onBlur(); }} onChange={onChange} diff --git a/apps/modernization-ui/src/apps/patient/data/phoneEmail/PhoneEmailEntryFields.spec.tsx b/apps/modernization-ui/src/apps/patient/data/phoneEmail/PhoneEmailEntryFields.spec.tsx index 168f3f2f76..b7d266f9d3 100644 --- a/apps/modernization-ui/src/apps/patient/data/phoneEmail/PhoneEmailEntryFields.spec.tsx +++ b/apps/modernization-ui/src/apps/patient/data/phoneEmail/PhoneEmailEntryFields.spec.tsx @@ -157,4 +157,19 @@ describe('when entering patient phone & email demographics', () => { } }); }); + + it('should verify email field', async () => { + const { getByLabelText, getByText } = render(); + const email = getByLabelText('Email'); + + userEvent.paste(email, 'invalid-email'); + userEvent.tab(); + + await waitFor(() => { + const validationError = getByText( + 'Please enter Email as an email address (example: youremail@website.com).' + ); + expect(validationError).toBeInTheDocument(); + }); + }); }); diff --git a/apps/modernization-ui/src/apps/patient/data/phoneEmail/PhoneEmailEntryFields.tsx b/apps/modernization-ui/src/apps/patient/data/phoneEmail/PhoneEmailEntryFields.tsx index cbde14579c..ba8645ae9e 100644 --- a/apps/modernization-ui/src/apps/patient/data/phoneEmail/PhoneEmailEntryFields.tsx +++ b/apps/modernization-ui/src/apps/patient/data/phoneEmail/PhoneEmailEntryFields.tsx @@ -4,9 +4,8 @@ import { SingleSelect } from 'design-system/select'; import { EntryFieldsProps } from 'design-system/entry'; import { maxLengthRule, validateRequiredRule } from 'validation/entry'; import { Verification } from 'libs/verification'; -import { EmailField, validateEmail, PhoneNumberInputField, validPhoneNumberRule } from 'libs/demographics/contact'; -import { MaskedTextInputField } from 'design-system/input/text'; -import { Input } from 'components/FormInputs/Input'; +import { EmailField, PhoneNumberInputField, validPhoneNumberRule, maybeValidateEmail } from 'libs/demographics/contact'; +import { MaskedTextInputField, TextInputField } from 'design-system/input/text'; import { PhoneEmailEntry } from 'apps/patient/data'; import { usePatientPhoneCodedValues } from 'apps/patient/profile/phoneEmail/usePatientPhoneCodedValues'; import { TextAreaField } from 'design-system/input/text/TextAreaField'; @@ -152,13 +151,15 @@ export const PhoneEmailEntryFields = ({ orientation = 'horizontal' }: PhoneEmail rules={maxLengthRule(100, EMAIL_LABEL)} render={({ field: { onChange, onBlur, value, name }, fieldState: { error } }) => ( ( { - verify(value); + verify(); onBlur(); }} onChange={onChange} @@ -176,16 +177,13 @@ export const PhoneEmailEntryFields = ({ orientation = 'horizontal' }: PhoneEmail name="url" rules={maxLengthRule(100, URL_LABEL)} render={({ field: { onChange, onBlur, value, name }, fieldState: { error } }) => ( - )} diff --git a/apps/modernization-ui/src/apps/patient/data/phoneEmail/entry.ts b/apps/modernization-ui/src/apps/patient/data/phoneEmail/entry.ts index 08c33d9b9c..1ce2262397 100644 --- a/apps/modernization-ui/src/apps/patient/data/phoneEmail/entry.ts +++ b/apps/modernization-ui/src/apps/patient/data/phoneEmail/entry.ts @@ -19,12 +19,12 @@ const initial = (asOf: string = today()): Partial => ({ asOf, type: undefined, use: undefined, - countryCode: '', - phoneNumber: '', - extension: '', - email: '', - url: '', - comment: '' + countryCode: undefined, + phoneNumber: undefined, + extension: undefined, + email: undefined, + url: undefined, + comment: undefined }); export { initial }; diff --git a/apps/modernization-ui/src/design-system/entry/index.ts b/apps/modernization-ui/src/design-system/entry/index.ts index 0a57021171..a13ea234c9 100644 --- a/apps/modernization-ui/src/design-system/entry/index.ts +++ b/apps/modernization-ui/src/design-system/entry/index.ts @@ -1,7 +1,8 @@ -import { Orientation } from 'components/Entry'; +import { Orientation, Sizing } from 'components/Entry'; type EntryFieldsProps = { orientation?: Orientation; + sizing?: Sizing; }; export type { EntryFieldsProps }; diff --git a/apps/modernization-ui/src/libs/demographics/contact/email/maybeValidateEmail.ts b/apps/modernization-ui/src/libs/demographics/contact/email/maybeValidateEmail.ts new file mode 100644 index 0000000000..381069a30a --- /dev/null +++ b/apps/modernization-ui/src/libs/demographics/contact/email/maybeValidateEmail.ts @@ -0,0 +1,13 @@ +import { validateIfPresent } from 'validation'; +import { validateEmail } from './validateEmail'; + +/** + * Validates that the given string represents a valid email. Any failed + * validations will include the name of the field being validated. + * + * @param {string} name The name of the field being validated + * @return {Validator} + */ +const maybeValidateEmail = (name: string) => validateIfPresent(validateEmail(name)); + +export { maybeValidateEmail }; diff --git a/apps/modernization-ui/src/libs/demographics/contact/email/validateEmailRule.ts b/apps/modernization-ui/src/libs/demographics/contact/email/validateEmailRule.ts index 82a54f153d..50f6596961 100644 --- a/apps/modernization-ui/src/libs/demographics/contact/email/validateEmailRule.ts +++ b/apps/modernization-ui/src/libs/demographics/contact/email/validateEmailRule.ts @@ -1,9 +1,8 @@ -import { validateIfPresent } from 'validation'; import { maxLengthRule } from 'validation/entry'; -import { validateEmail } from './validateEmail'; +import { maybeValidateEmail } from './maybeValidateEmail'; const validEmailRule = (name: string) => ({ - validate: validateIfPresent(validateEmail(name)), + validate: maybeValidateEmail(name), ...maxLengthRule(100, name) }); diff --git a/apps/modernization-ui/src/libs/demographics/contact/index.ts b/apps/modernization-ui/src/libs/demographics/contact/index.ts index 574fe487a5..3edaa0f8c5 100644 --- a/apps/modernization-ui/src/libs/demographics/contact/index.ts +++ b/apps/modernization-ui/src/libs/demographics/contact/index.ts @@ -3,4 +3,5 @@ export { validPhoneNumberRule } from './phone/validPhoneNumberRule'; export { EmailField } from './email/EmailField'; export { validateEmail } from './email/validateEmail'; +export { maybeValidateEmail } from './email/maybeValidateEmail'; export { validEmailRule } from './email/validateEmailRule'; diff --git a/apps/modernization-ui/src/libs/verification/Verification.tsx b/apps/modernization-ui/src/libs/verification/Verification.tsx index 2b25702393..dfa9396425 100644 --- a/apps/modernization-ui/src/libs/verification/Verification.tsx +++ b/apps/modernization-ui/src/libs/verification/Verification.tsx @@ -1,20 +1,27 @@ -import { Validator } from 'validation'; -import { useVerification } from './useVerification'; +import { FieldPath, FieldValues } from 'react-hook-form'; +import { useVerification, VerificationOptions } from './useVerification'; -type VerificationRenderProps = { +type VerificationRenderProps = { violation?: string; - verify: (value?: T) => void; + verify: () => void; }; -type VerificationProps = { - constraint: Validator; - render: (result: VerificationRenderProps) => JSX.Element; +type VerificationProps< + Values extends FieldValues = FieldValues, + Name extends FieldPath = FieldPath +> = VerificationOptions & { + render: (result: VerificationRenderProps) => JSX.Element; }; -const Verification = ({ constraint, render }: VerificationProps) => { - const { violation, verify } = useVerification({ constraint }); +const Verification = = FieldPath>({ + name, + control, + constraint, + render +}: VerificationProps) => { + const { violation, verify } = useVerification({ name, control, constraint }); - return render({ violation, verify }); + return render({ verify, violation: violation?.message }); }; export { Verification }; diff --git a/apps/modernization-ui/src/libs/verification/useVerification.ts b/apps/modernization-ui/src/libs/verification/useVerification.ts index ca223a29d5..8127e079eb 100644 --- a/apps/modernization-ui/src/libs/verification/useVerification.ts +++ b/apps/modernization-ui/src/libs/verification/useVerification.ts @@ -1,36 +1,57 @@ -import { useCallback, useState } from 'react'; -import { validateIfPresent, Validator } from 'validation'; +import { useCallback, useEffect, useState } from 'react'; +import { Control, FieldPath, FieldPathValue, FieldValues, useWatch } from 'react-hook-form'; +import { Validator } from 'validation'; -type VerificationOptions = { - constraint: Validator; +type Violation = { + value: V; + message: string | undefined; }; -type VerificationInteraction = { - verify: (value?: T) => void; - violation?: string; +type VerificationOptions< + Values extends FieldValues = FieldValues, + Name extends FieldPath = FieldPath +> = { + name: Name; + control: Control; + constraint: Validator>; +}; + +type VerificationInteraction = { + verify: () => void; + violation?: Violation; }; /** - * Applies a constraint to a value and provides any violations if the constraint fails. + * Applies a constraint to a value providing any violations if the constraint fails. * - * @param {VerificationOptions} param0 The options for the verification, including the constraint to apply for verification. + * @param {VerificationOptions} param0 The options for the verification, including + * - the name of the field that is being verified. + * - the control of the form that is being verified. + * - the constraint to apply for verification. * @return {VerificationInteraction} The interactions with the hook providing the verify function and any violations. */ -const useVerification = ({ constraint }: VerificationOptions): VerificationInteraction => { - const [violation, setViolation] = useState(); - - const verify = useCallback( - (value?: V) => { - const result = validateIfPresent(constraint)(value); - - if (typeof result === 'string') { - setViolation(result); - } else { - setViolation(undefined); - } - }, - [setViolation, constraint] - ); +const useVerification = = FieldPath>({ + name, + control, + constraint +}: VerificationOptions): VerificationInteraction => { + const [violation, setViolation] = useState>>(); + + const current = useWatch({ control, name }); + + useEffect(() => { + setViolation((existing) => (current !== existing?.value ? undefined : existing)); + }, [current, setViolation]); + + const verify = useCallback(() => { + const result = constraint(current); + + if (typeof result === 'string') { + setViolation({ value: current, message: result }); + } else { + setViolation(undefined); + } + }, [setViolation, constraint, current]); return { verify, @@ -39,3 +60,4 @@ const useVerification = ({ constraint }: VerificationOptions): Verificatio }; export { useVerification }; +export type { VerificationOptions }; diff --git a/apps/modernization-ui/src/libs/verification/userVerification.spec.ts b/apps/modernization-ui/src/libs/verification/userVerification.spec.ts deleted file mode 100644 index 3c7060b674..0000000000 --- a/apps/modernization-ui/src/libs/verification/userVerification.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { useVerification } from './useVerification'; -import { act } from '@testing-library/react'; - -describe('when verifying values', () => { - it('should provide violations when the constraint fails', () => { - const { result } = renderHook(() => useVerification({ constraint: () => 'value is unverified.' })); - - act(() => { - result.current.verify('value'); - }); - - expect(result.current.violation).toContain('value is unverified'); - }); - - it('should not provide violations until asked to verify', () => { - const { result } = renderHook(() => useVerification({ constraint: () => 'value is unverified.' })); - - expect(result.current.violation).toBeUndefined(); - }); - - it('should not provide violations when the constraint is met', () => { - const { result } = renderHook(() => useVerification({ constraint: () => true })); - - act(() => { - result.current.verify('value'); - }); - - expect(result.current.violation).toBeUndefined(); - }); -});