From 6d14bf3f386523bacd6832e56cc5903f644da88e Mon Sep 17 00:00:00 2001 From: Ioana Brooks <68251134+ioanabrooks@users.noreply.github.com> Date: Mon, 17 Jul 2023 10:32:40 -0700 Subject: [PATCH] chore(ui-react-native): Add RN Authenticator fields validations (#4107) --- .changeset/rotten-lies-promise.md | 6 ++ .../authenticator/sign-up-with-email.feature | 7 ++ .../src/Authenticator/hooks/types.ts | 21 +++-- packages/react-native/jest.config.js | 1 + .../ConfirmResetPassword.tsx | 4 + .../Defaults/ConfirmSignIn/ConfirmSignIn.tsx | 4 + .../__tests__/ConfirmSignIn.spec.tsx | 1 + .../Defaults/ConfirmSignUp/ConfirmSignUp.tsx | 4 + .../__tests__/ConfirmSignUp.spec.tsx | 1 + .../ConfirmVerifyUser/ConfirmVerifyUser.tsx | 4 + .../__tests__/ConfirmVerifyUser.spec.tsx | 1 + .../ForceNewPassword/ForceNewPassword.tsx | 4 + .../Defaults/ResetPassword/ResetPassword.tsx | 4 + .../__tests__/ResetPassword.spec.tsx | 1 + .../Defaults/SetupTOTP/SetupTOTP.tsx | 4 + .../SetupTOTP/__tests__/SetupTOTP.spec.tsx | 1 + .../Authenticator/Defaults/SignIn/SignIn.tsx | 4 + .../Defaults/SignIn/__tests__/SignIn.spec.tsx | 1 + .../Authenticator/Defaults/SignUp/SignUp.tsx | 4 + .../Defaults/VerifyUser/VerifyUser.tsx | 4 + .../VerifyUser/__tests__/VerifyUser.spec.tsx | 1 + .../src/Authenticator/hooks/types.ts | 3 + .../__tests__/useFieldValues.spec.ts | 77 ++++++++++++++++++- .../useFieldValues/__tests__/utils.spec.ts | 68 +++++++++++++++- .../hooks/useFieldValues/types.ts | 5 ++ .../hooks/useFieldValues/useFieldValues.ts | 27 ++++++- .../hooks/useFieldValues/utils.ts | 45 ++++++++++- .../authenticator/__tests__/utils.test.ts | 28 ++++++- .../ui/src/helpers/authenticator/constants.ts | 8 ++ .../ui/src/helpers/authenticator/textUtil.ts | 6 ++ .../ui/src/helpers/authenticator/utils.ts | 8 +- 31 files changed, 343 insertions(+), 14 deletions(-) create mode 100644 .changeset/rotten-lies-promise.md diff --git a/.changeset/rotten-lies-promise.md b/.changeset/rotten-lies-promise.md new file mode 100644 index 00000000000..f886cfdcb46 --- /dev/null +++ b/.changeset/rotten-lies-promise.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/ui-react-native': patch +'@aws-amplify/ui-react-core': patch +--- + +chore(ui-react-native): Add Authenticator fields validations diff --git a/packages/e2e/features/ui/components/authenticator/sign-up-with-email.feature b/packages/e2e/features/ui/components/authenticator/sign-up-with-email.feature index 76249680323..1612e247532 100644 --- a/packages/e2e/features/ui/components/authenticator/sign-up-with-email.feature +++ b/packages/e2e/features/ui/components/authenticator/sign-up-with-email.feature @@ -51,6 +51,13 @@ Scenario: Sign up with a new email & password And I click the "Create Account" button Then I see "Confirmation Code" +@react-native +Scenario: Sign up using invalid email + When I type a new "email" with value '' + Then I see "This field is required" + When I type a new "email" with value 'inv' + Then I see "Please enter a valid email" + @angular @react @vue Scenario: Email field autocompletes username diff --git a/packages/react-core/src/Authenticator/hooks/types.ts b/packages/react-core/src/Authenticator/hooks/types.ts index 00dce3e4bc1..dc3e5ed4708 100644 --- a/packages/react-core/src/Authenticator/hooks/types.ts +++ b/packages/react-core/src/Authenticator/hooks/types.ts @@ -95,18 +95,21 @@ export type ConfirmSignInBaseProps = { challengeName: AuthChallengeName; toSignIn: UseAuthenticator['toSignIn']; } & CommonRouteProps & - ComponentSlots; + ComponentSlots & + ValidationProps; export type ConfirmSignUpBaseProps = { codeDeliveryDetails: UseAuthenticator['codeDeliveryDetails']; resendCode: UseAuthenticator['resendCode']; } & CommonRouteProps & - ComponentSlots; + ComponentSlots & + ValidationProps; export type ConfirmVerifyUserProps = { skipVerification: UseAuthenticator['skipVerification']; } & CommonRouteProps & - ComponentSlots; + ComponentSlots & + ValidationProps; export type ForceResetPasswordBaseProps = { toSignIn: UseAuthenticator['toSignIn']; @@ -117,13 +120,15 @@ export type ForceResetPasswordBaseProps = { export type ResetPasswordBaseProps = { toSignIn: UseAuthenticator['toSignIn']; } & CommonRouteProps & - ComponentSlots; + ComponentSlots & + ValidationProps; export type SetupTOTPBaseProps = { toSignIn: UseAuthenticator['toSignIn']; totpSecretCode: UseAuthenticator['totpSecretCode']; } & CommonRouteProps & - ComponentSlots; + ComponentSlots & + ValidationProps; export type SignInBaseProps = { hideSignUp?: boolean; @@ -131,7 +136,8 @@ export type SignInBaseProps = { toResetPassword: UseAuthenticator['toResetPassword']; toSignUp: UseAuthenticator['toSignUp']; } & CommonRouteProps & - ComponentSlots; + ComponentSlots & + ValidationProps; export type SignUpBaseProps = { hideSignIn?: boolean; @@ -144,7 +150,8 @@ export type SignUpBaseProps = { export type VerifyUserProps = { skipVerification: UseAuthenticator['skipVerification']; } & CommonRouteProps & - ComponentSlots; + ComponentSlots & + ValidationProps; export interface DefaultProps { ConfirmSignIn: ConfirmSignInBaseProps; diff --git a/packages/react-native/jest.config.js b/packages/react-native/jest.config.js index a07d7631417..ef430341567 100644 --- a/packages/react-native/jest.config.js +++ b/packages/react-native/jest.config.js @@ -1,6 +1,7 @@ module.exports = { preset: 'react-native', modulePathIgnorePatterns: ['/dist/'], + collectCoverage: true, collectCoverageFrom: [ '/src/**/*.{js,jsx,ts,tsx}', '!/src/**/*{c,C}onstants.ts', diff --git a/packages/react-native/src/Authenticator/Defaults/ConfirmResetPassword/ConfirmResetPassword.tsx b/packages/react-native/src/Authenticator/Defaults/ConfirmResetPassword/ConfirmResetPassword.tsx index 86a7ab382a0..3b4d1b2b5bb 100644 --- a/packages/react-native/src/Authenticator/Defaults/ConfirmResetPassword/ConfirmResetPassword.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ConfirmResetPassword/ConfirmResetPassword.tsx @@ -28,11 +28,13 @@ const ConfirmResetPassword = ({ hasValidationErrors, isPending, resendCode, + validationErrors, ...rest }: DefaultConfirmResetPasswordProps): JSX.Element => { const { disableFormSubmit, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -40,6 +42,7 @@ const ConfirmResetPassword = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const disabled = hasValidationErrors || disableFormSubmit; @@ -72,6 +75,7 @@ const ConfirmResetPassword = ({ headerText={headerText} fields={fieldsWithHandlers} isPending={isPending} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/ConfirmSignIn/ConfirmSignIn.tsx b/packages/react-native/src/Authenticator/Defaults/ConfirmSignIn/ConfirmSignIn.tsx index 3b09d38cea4..c63a4e8b0fe 100644 --- a/packages/react-native/src/Authenticator/Defaults/ConfirmSignIn/ConfirmSignIn.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ConfirmSignIn/ConfirmSignIn.tsx @@ -28,11 +28,13 @@ const ConfirmSignIn = ({ handleSubmit, isPending, toSignIn, + validationErrors, ...rest }: DefaultConfirmSignInProps): JSX.Element => { const { disableFormSubmit: disabled, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -40,6 +42,7 @@ const ConfirmSignIn = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const headerText = getChallengeText(challengeName); @@ -71,6 +74,7 @@ const ConfirmSignIn = ({ headerText={headerText} fields={fieldsWithHandlers} isPending={isPending} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/ConfirmSignIn/__tests__/ConfirmSignIn.spec.tsx b/packages/react-native/src/Authenticator/Defaults/ConfirmSignIn/__tests__/ConfirmSignIn.spec.tsx index 079883f017b..7bce0416462 100644 --- a/packages/react-native/src/Authenticator/Defaults/ConfirmSignIn/__tests__/ConfirmSignIn.spec.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ConfirmSignIn/__tests__/ConfirmSignIn.spec.tsx @@ -21,6 +21,7 @@ const props = { handleBlur: jest.fn(), handleChange: jest.fn(), handleSubmit: jest.fn(), + hasValidationErrors: false, Header: ConfirmSignIn.Header, isPending: false, toSignIn: jest.fn(), diff --git a/packages/react-native/src/Authenticator/Defaults/ConfirmSignUp/ConfirmSignUp.tsx b/packages/react-native/src/Authenticator/Defaults/ConfirmSignUp/ConfirmSignUp.tsx index 0f3085c05df..150a8adbf2d 100644 --- a/packages/react-native/src/Authenticator/Defaults/ConfirmSignUp/ConfirmSignUp.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ConfirmSignUp/ConfirmSignUp.tsx @@ -29,11 +29,13 @@ const ConfirmSignUp = ({ handleSubmit, isPending, resendCode, + validationErrors, ...rest }: DefaultConfirmSignUpProps): JSX.Element => { const { disableFormSubmit: disabled, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -41,6 +43,7 @@ const ConfirmSignUp = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const headerText = getDeliveryMethodText(codeDeliveryDetails); @@ -74,6 +77,7 @@ const ConfirmSignUp = ({ headerText={headerText} fields={fieldsWithHandlers} isPending={isPending} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/ConfirmSignUp/__tests__/ConfirmSignUp.spec.tsx b/packages/react-native/src/Authenticator/Defaults/ConfirmSignUp/__tests__/ConfirmSignUp.spec.tsx index 8f531a9e7ff..57275d63ea4 100644 --- a/packages/react-native/src/Authenticator/Defaults/ConfirmSignUp/__tests__/ConfirmSignUp.spec.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ConfirmSignUp/__tests__/ConfirmSignUp.spec.tsx @@ -16,6 +16,7 @@ const props = { handleBlur: jest.fn(), handleChange: jest.fn(), handleSubmit: jest.fn(), + hasValidationErrors: false, Header: ConfirmSignUp.Header, isPending: false, resendCode: jest.fn(), diff --git a/packages/react-native/src/Authenticator/Defaults/ConfirmVerifyUser/ConfirmVerifyUser.tsx b/packages/react-native/src/Authenticator/Defaults/ConfirmVerifyUser/ConfirmVerifyUser.tsx index 24a3d9bd4f6..a3b100d7d96 100644 --- a/packages/react-native/src/Authenticator/Defaults/ConfirmVerifyUser/ConfirmVerifyUser.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ConfirmVerifyUser/ConfirmVerifyUser.tsx @@ -27,11 +27,13 @@ const ConfirmVerifyUser = ({ handleSubmit, isPending, skipVerification, + validationErrors, ...rest }: DefaultConfirmVerifyUserProps): JSX.Element => { const { disableFormSubmit: disabled, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -39,6 +41,7 @@ const ConfirmVerifyUser = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const headerText = getAccountRecoveryInfoText(); @@ -70,6 +73,7 @@ const ConfirmVerifyUser = ({ headerText={headerText} fields={fieldsWithHandlers} isPending={isPending} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/ConfirmVerifyUser/__tests__/ConfirmVerifyUser.spec.tsx b/packages/react-native/src/Authenticator/Defaults/ConfirmVerifyUser/__tests__/ConfirmVerifyUser.spec.tsx index 30482ae703f..f5f028c3236 100644 --- a/packages/react-native/src/Authenticator/Defaults/ConfirmVerifyUser/__tests__/ConfirmVerifyUser.spec.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ConfirmVerifyUser/__tests__/ConfirmVerifyUser.spec.tsx @@ -18,6 +18,7 @@ const props = { handleBlur: jest.fn(), handleChange: jest.fn(), handleSubmit: jest.fn(), + hasValidationErrors: false, Header: ConfirmVerifyUser.Header, isPending: false, skipVerification: jest.fn(), diff --git a/packages/react-native/src/Authenticator/Defaults/ForceNewPassword/ForceNewPassword.tsx b/packages/react-native/src/Authenticator/Defaults/ForceNewPassword/ForceNewPassword.tsx index 0d37641d313..4943d3137b6 100644 --- a/packages/react-native/src/Authenticator/Defaults/ForceNewPassword/ForceNewPassword.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ForceNewPassword/ForceNewPassword.tsx @@ -24,11 +24,13 @@ const ForceNewPassword = ({ hasValidationErrors, isPending, toSignIn, + validationErrors, ...rest }: DefaultForceNewPasswordProps): JSX.Element => { const { disableFormSubmit, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -36,6 +38,7 @@ const ForceNewPassword = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const disabled = hasValidationErrors || disableFormSubmit; @@ -70,6 +73,7 @@ const ForceNewPassword = ({ headerText={headerText} fields={fieldsWithHandlers} isPending={isPending} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/ResetPassword/ResetPassword.tsx b/packages/react-native/src/Authenticator/Defaults/ResetPassword/ResetPassword.tsx index cccbeab42f3..35e48eb9278 100644 --- a/packages/react-native/src/Authenticator/Defaults/ResetPassword/ResetPassword.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ResetPassword/ResetPassword.tsx @@ -26,11 +26,13 @@ const ResetPassword = ({ handleSubmit, isPending, toSignIn, + validationErrors, ...rest }: DefaultResetPasswordProps): JSX.Element => { const { disableFormSubmit: disabled, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -38,6 +40,7 @@ const ResetPassword = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const headerText = getResetYourPasswordText(); @@ -69,6 +72,7 @@ const ResetPassword = ({ headerText={headerText} fields={fieldsWithHandlers} isPending={isPending} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/ResetPassword/__tests__/ResetPassword.spec.tsx b/packages/react-native/src/Authenticator/Defaults/ResetPassword/__tests__/ResetPassword.spec.tsx index ea0fb397602..fb14df5cbe0 100644 --- a/packages/react-native/src/Authenticator/Defaults/ResetPassword/__tests__/ResetPassword.spec.tsx +++ b/packages/react-native/src/Authenticator/Defaults/ResetPassword/__tests__/ResetPassword.spec.tsx @@ -21,6 +21,7 @@ const props = { handleBlur: jest.fn(), handleChange: jest.fn(), handleSubmit: jest.fn(), + hasValidationErrors: false, Header: ResetPassword.Header, isPending: false, toSignIn: jest.fn(), diff --git a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/SetupTOTP.tsx b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/SetupTOTP.tsx index 89d38d5c14a..a32c7a203b2 100644 --- a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/SetupTOTP.tsx +++ b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/SetupTOTP.tsx @@ -33,11 +33,13 @@ const SetupTOTP = ({ isPending, toSignIn, totpSecretCode, + validationErrors, ...rest }: DefaultSetupTOTPProps): JSX.Element => { const { disableFormSubmit: disabled, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -45,6 +47,7 @@ const SetupTOTP = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const headerText = getSetupTOTPText(); @@ -88,6 +91,7 @@ const SetupTOTP = ({ headerText={headerText} fields={fieldsWithHandlers} isPending={isPending} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/SetupTOTP.spec.tsx b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/SetupTOTP.spec.tsx index fc942b3240f..6ad1255e983 100644 --- a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/SetupTOTP.spec.tsx +++ b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/SetupTOTP.spec.tsx @@ -22,6 +22,7 @@ const props = { handleBlur: jest.fn(), handleChange: jest.fn(), handleSubmit: jest.fn(), + hasValidationErrors: false, isPending: false, toSignIn, totpSecretCode: "Let's keep it hush hush", diff --git a/packages/react-native/src/Authenticator/Defaults/SignIn/SignIn.tsx b/packages/react-native/src/Authenticator/Defaults/SignIn/SignIn.tsx index 850299b2c44..2aef603d4ef 100644 --- a/packages/react-native/src/Authenticator/Defaults/SignIn/SignIn.tsx +++ b/packages/react-native/src/Authenticator/Defaults/SignIn/SignIn.tsx @@ -21,6 +21,7 @@ const SignIn = ({ hideSignUp, toResetPassword, toSignUp, + validationErrors, ...rest }: DefaultSignInProps): JSX.Element => { const { @@ -33,6 +34,7 @@ const SignIn = ({ const { disableFormSubmit: disabled, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -40,6 +42,7 @@ const SignIn = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const headerText = getSignInTabText(); @@ -75,6 +78,7 @@ const SignIn = ({ buttons={buttons} fields={fieldsWithHandlers} headerText={headerText} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/SignIn/__tests__/SignIn.spec.tsx b/packages/react-native/src/Authenticator/Defaults/SignIn/__tests__/SignIn.spec.tsx index cc1fca9bbd4..69d05201c4e 100644 --- a/packages/react-native/src/Authenticator/Defaults/SignIn/__tests__/SignIn.spec.tsx +++ b/packages/react-native/src/Authenticator/Defaults/SignIn/__tests__/SignIn.spec.tsx @@ -29,6 +29,7 @@ const props = { handleBlur: jest.fn(), handleChange: jest.fn(), handleSubmit: jest.fn(), + hasValidationErrors: false, Header: SignIn.Header, isPending: false, socialProviders: undefined, diff --git a/packages/react-native/src/Authenticator/Defaults/SignUp/SignUp.tsx b/packages/react-native/src/Authenticator/Defaults/SignUp/SignUp.tsx index 5d75a181677..b185b3e3daa 100644 --- a/packages/react-native/src/Authenticator/Defaults/SignUp/SignUp.tsx +++ b/packages/react-native/src/Authenticator/Defaults/SignUp/SignUp.tsx @@ -29,11 +29,13 @@ const SignUp = ({ hideSignIn, isPending, toSignIn, + validationErrors, ...rest }: DefaultSignUpProps): JSX.Element => { const { disableFormSubmit, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -41,6 +43,7 @@ const SignUp = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const disabled = hasValidationErrors || disableFormSubmit; @@ -78,6 +81,7 @@ const SignUp = ({ fields={fieldsWithHandlers} headerText={headerText} isPending={isPending} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/VerifyUser/VerifyUser.tsx b/packages/react-native/src/Authenticator/Defaults/VerifyUser/VerifyUser.tsx index f2c690f9757..0047d5f85b9 100644 --- a/packages/react-native/src/Authenticator/Defaults/VerifyUser/VerifyUser.tsx +++ b/packages/react-native/src/Authenticator/Defaults/VerifyUser/VerifyUser.tsx @@ -25,11 +25,13 @@ const VerifyUser = ({ handleChange, handleSubmit, skipVerification, + validationErrors, ...rest }: DefaultVerifyUserProps): JSX.Element => { const { disableFormSubmit: disabled, fields: fieldsWithHandlers, + fieldValidationErrors, handleFormSubmit, } = useFieldValues({ componentName: COMPONENT_NAME, @@ -37,6 +39,7 @@ const VerifyUser = ({ handleBlur, handleChange, handleSubmit, + validationErrors, }); const headerText = getVerifyContactText(); @@ -59,6 +62,7 @@ const VerifyUser = ({ buttons={buttons} fields={fieldsWithHandlers} headerText={headerText} + validationErrors={fieldValidationErrors} /> ); }; diff --git a/packages/react-native/src/Authenticator/Defaults/VerifyUser/__tests__/VerifyUser.spec.tsx b/packages/react-native/src/Authenticator/Defaults/VerifyUser/__tests__/VerifyUser.spec.tsx index 8575d805332..eba0bb69517 100644 --- a/packages/react-native/src/Authenticator/Defaults/VerifyUser/__tests__/VerifyUser.spec.tsx +++ b/packages/react-native/src/Authenticator/Defaults/VerifyUser/__tests__/VerifyUser.spec.tsx @@ -44,6 +44,7 @@ const props = { handleBlur: jest.fn(), handleChange: jest.fn(), handleSubmit, + hasValidationErrors: false, Header: VerifyUser.Header, isPending: false, skipVerification: jest.fn(), diff --git a/packages/react-native/src/Authenticator/hooks/types.ts b/packages/react-native/src/Authenticator/hooks/types.ts index 922900d4860..e71a227581b 100644 --- a/packages/react-native/src/Authenticator/hooks/types.ts +++ b/packages/react-native/src/Authenticator/hooks/types.ts @@ -7,6 +7,7 @@ import { export type MachineFieldTypeKey = 'password' | 'tel'; export type AuthenticatorFieldTypeKey = + | 'email' | 'password' | 'phone' | 'default' @@ -23,10 +24,12 @@ type FieldOptions = { type: Type; } & Omit; +type EmailFieldOptions = FieldOptions; type PasswordFieldOptions = FieldOptions; type PhoneFieldOptions = FieldOptions; type DefaultFieldOptions = FieldOptions; export type TextFieldOptionsType = ( + | EmailFieldOptions | PasswordFieldOptions | PhoneFieldOptions | DefaultFieldOptions diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/useFieldValues.spec.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/useFieldValues.spec.ts index 1a8558fae9c..cd0e888fcae 100644 --- a/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/useFieldValues.spec.ts +++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/useFieldValues.spec.ts @@ -2,7 +2,10 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { NativeSyntheticEvent, TextInputFocusEventData } from 'react-native'; import { Logger } from 'aws-amplify'; -import { UnverifiedContactMethodType } from '@aws-amplify/ui'; +import { + UnverifiedContactMethodType, + authenticatorTextUtil, +} from '@aws-amplify/ui'; import { RadioFieldOptions, TextFieldOptionsType, @@ -58,6 +61,7 @@ describe('useFieldValues', () => { value: undefined, }, ], + fieldValidationErrors: {}, handleFormSubmit: expect.any(Function), }); }); @@ -80,6 +84,7 @@ describe('useFieldValues', () => { value: undefined, }, ], + fieldValidationErrors: {}, handleFormSubmit: expect.any(Function), }); }); @@ -95,6 +100,7 @@ describe('useFieldValues', () => { expect(result.current).toStrictEqual({ disableFormSubmit: true, fields: [{ ...radioField, onChange: expect.any(Function) }], + fieldValidationErrors: {}, handleFormSubmit: expect.any(Function), }); }); @@ -105,6 +111,7 @@ describe('useFieldValues', () => { expect(result.current).toStrictEqual({ disableFormSubmit: false, fields: mockfields, + fieldValidationErrors: {}, handleFormSubmit: expect.any(Function), }); }); @@ -129,7 +136,9 @@ describe('useFieldValues', () => { const mockEvent = { nativeEvent: { target: 1 }, } as NativeSyntheticEvent; - result.current.fields[0].onBlur?.(mockEvent); + act(() => { + result.current.fields[0].onBlur?.(mockEvent); + }); expect(props.handleBlur).toHaveBeenCalledTimes(1); expect(props.handleBlur).toHaveBeenCalledWith({ name: textField.name, @@ -153,6 +162,68 @@ describe('useFieldValues', () => { }); }); + it('runs validations for email fields', () => { + const emailField = { + label: 'test', + type: 'email', + name: 'invalid_email', + value: 'test@', + } as TextFieldOptionsType; + const phoneTextField = { + type: 'phone', + name: 'testPhone', + } as TextFieldOptionsType; + const { result } = renderHook(() => + useFieldValues({ + ...props, + fields: [emailField, phoneTextField], + }) + ); + const mockEvent = { + nativeEvent: { target: 1 }, + } as NativeSyntheticEvent; + act(() => { + result.current.fields[0].onBlur?.(mockEvent); + }); + expect(props.handleBlur).toHaveBeenCalledTimes(1); + expect(props.handleBlur).toHaveBeenCalledWith({ + name: emailField.name, + value: undefined, + }); + expect(result.current.fieldValidationErrors).toStrictEqual({ + [emailField.name]: [authenticatorTextUtil.getInvalidEmailText()], + }); + }); + + it('runs validations for required fields', () => { + const requiredField = { + label: 'test', + type: 'password', + name: 'required', + required: true, + } as TextFieldOptionsType; + const { result } = renderHook(() => + useFieldValues({ + ...props, + fields: [requiredField], + }) + ); + const mockEvent = { + nativeEvent: { target: 1 }, + } as NativeSyntheticEvent; + act(() => { + result.current.fields[0].onBlur?.(mockEvent); + }); + expect(props.handleBlur).toHaveBeenCalledTimes(1); + expect(props.handleBlur).toHaveBeenCalledWith({ + name: requiredField.name, + value: undefined, + }); + expect(result.current.fieldValidationErrors).toStrictEqual({ + [requiredField.name]: [authenticatorTextUtil.getRequiredFieldText()], + }); + }); + it('calls expected handlers for radios', () => { const { result } = renderHook(() => useFieldValues({ @@ -234,6 +305,7 @@ describe('useFieldValues', () => { value: undefined, }, ], + fieldValidationErrors: {}, handleFormSubmit: expect.any(Function), }); }); @@ -267,6 +339,7 @@ describe('useFieldValues', () => { value: mockValue, }, ], + fieldValidationErrors: {}, handleFormSubmit: expect.any(Function), }); }); diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/utils.spec.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/utils.spec.ts index 3745c8942f5..d7f95fdebec 100644 --- a/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/utils.spec.ts +++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/utils.spec.ts @@ -1,10 +1,12 @@ import { Logger } from 'aws-amplify'; -import { TypedField } from '../../types'; +import { authenticatorTextUtil } from '@aws-amplify/ui'; +import { TextFieldOptionsType, TypedField } from '../../types'; import { getRouteTypedFields, getSanitizedRadioFields, getSanitizedTextFields, + runFieldValidation, } from '../utils'; const warnSpy = jest.spyOn(Logger.prototype, 'warn'); @@ -214,3 +216,67 @@ describe('getRouteTypedFields', () => { expect(fields).toStrictEqual(expected); }); }); + +describe('runFieldValidation', () => { + const { getInvalidEmailText, getRequiredFieldText } = authenticatorTextUtil; + const field: TextFieldOptionsType = { + required: true, + type: 'email', + name: 'email', + }; + + it('should return an empty array when no errors are found', () => { + const value = 'test@example.com'; + const stateValidations = {}; + + const result = runFieldValidation(field, value, stateValidations); + + expect(result).toEqual([]); + }); + + it('should return an array with the required field error when value is missing', () => { + const value = undefined; + const stateValidations = {}; + + const result = runFieldValidation(field, value, stateValidations); + + expect(result).toEqual([getRequiredFieldText(), getInvalidEmailText()]); + }); + + it('should return an array with the invalid email error when email value is invalid', () => { + const value = 'invalid-email'; + const stateValidations = {}; + + const result = runFieldValidation(field, value, stateValidations); + + expect(result).toEqual([getInvalidEmailText()]); + }); + + it('should include state machine validation errors in the result', () => { + const value = 'test@example.com'; + const errorMessage = 'Email already exists.'; + const stateValidations = { + email: errorMessage, + }; + + const result = runFieldValidation(field, value, stateValidations); + + expect(result).toEqual([errorMessage]); + }); + + it('should concatenate state machine validation errors with other errors', () => { + const value = undefined; + const errorMessage = 'Email already exists.'; + const stateValidations = { + email: errorMessage, + }; + + const result = runFieldValidation(field, value, stateValidations); + + expect(result).toEqual([ + getRequiredFieldText(), + getInvalidEmailText(), + errorMessage, + ]); + }); +}); diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/types.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/types.ts index 7a79cf443f3..a0e63790802 100644 --- a/packages/react-native/src/Authenticator/hooks/useFieldValues/types.ts +++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/types.ts @@ -1,7 +1,9 @@ import { AuthenticatorComponentDefaultProps, AuthenticatorRouteComponentName, + AuthenticatorMachineContext, } from '@aws-amplify/ui-react-core'; +import { ValidationError } from '@aws-amplify/ui'; import { TypedField } from '../types'; @@ -26,10 +28,13 @@ export interface UseFieldValuesParams { * machine "SUBMIT"" event handler, validates `field` value against machine validation rules */ handleSubmit: MachineEventHandlers['handleSubmit']; + + validationErrors?: AuthenticatorMachineContext['validationErrors']; } export interface UseFieldValues { fields: FieldType[]; // return either radio or text + fieldValidationErrors: ValidationError | undefined; disableFormSubmit: boolean; handleFormSubmit: () => void; } diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/useFieldValues.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/useFieldValues.ts index 2ec6e1c2a51..70c6a73aa40 100644 --- a/packages/react-native/src/Authenticator/hooks/useFieldValues/useFieldValues.ts +++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/useFieldValues.ts @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; import { Logger } from 'aws-amplify'; +import { ValidationError } from '@aws-amplify/ui'; import { OnChangeText, TextFieldOnBlur, TypedField } from '../types'; @@ -8,6 +9,7 @@ import { getSanitizedTextFields, getSanitizedRadioFields, isRadioFieldOptions, + runFieldValidation, } from './utils'; const logger = new Logger('Authenticator'); @@ -18,8 +20,12 @@ export default function useFieldValues({ handleBlur, handleChange, handleSubmit, + validationErrors, }: UseFieldValuesParams): UseFieldValues { const [values, setValues] = useState>({}); + const [touched, setTouched] = useState>({}); + const [fieldValidationErrors, setFieldValidationErrors] = + useState({}); const isRadioFieldComponent = componentName === 'VerifyUser'; const sanitizedFields = useMemo(() => { @@ -53,11 +59,18 @@ export default function useFieldValues({ const { name, label, labelHidden, ...rest } = field; const onBlur: TextFieldOnBlur = (event) => { + setTouched({ ...touched, [name]: true }); + // call `onBlur` passed as text `field` option field.onBlur?.(event); // call machine blur handler handleBlur({ name, value: values[name] }); + + setFieldValidationErrors({ + ...fieldValidationErrors, + [name]: runFieldValidation(field, values[name], validationErrors), + }); }; const onChangeText: OnChangeText = (value) => { @@ -67,6 +80,13 @@ export default function useFieldValues({ // call machine change handler handleChange({ name, value }); + if (touched[name]) { + setFieldValidationErrors({ + ...fieldValidationErrors, + [name]: runFieldValidation(field, value, validationErrors), + }); + } + setValues({ ...values, [name]: value }); }; @@ -112,5 +132,10 @@ export default function useFieldValues({ handleSubmit?.(submitValue); }; - return { fields: fieldsWithHandlers, disableFormSubmit, handleFormSubmit }; + return { + fields: fieldsWithHandlers, + disableFormSubmit, + fieldValidationErrors: { ...fieldValidationErrors, ...validationErrors }, + handleFormSubmit, + }; } diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/utils.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/utils.ts index 5e7292eb826..f7a657c188b 100644 --- a/packages/react-native/src/Authenticator/hooks/useFieldValues/utils.ts +++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/utils.ts @@ -1,7 +1,11 @@ import { Logger } from 'aws-amplify'; import { + authenticatorTextUtil, + isString, isUnverifiedContactMethodType, + isValidEmail, UnverifiedContactMethodType, + ValidationError, } from '@aws-amplify/ui'; import { AuthenticatorLegacyField, @@ -14,12 +18,15 @@ import { AuthenticatorFieldTypeKey, MachineFieldTypeKey, RadioFieldOptions, + TextFieldOptionsType, TypedField, } from '../types'; import { KEY_ALLOW_LIST } from './constants'; const logger = new Logger('Authenticator'); +const { getInvalidEmailText, getRequiredFieldText } = authenticatorTextUtil; + export const isRadioFieldOptions = ( field: TypedField ): field is RadioFieldOptions => field?.type === 'radio'; @@ -109,7 +116,8 @@ const isKeyAllowed = (key: string) => const isValidMachineFieldType = ( type: string | undefined -): type is MachineFieldTypeKey => type === 'password' || type === 'tel'; +): type is MachineFieldTypeKey => + type === 'password' || type === 'tel' || type == 'email'; const getFieldType = (type: string | undefined): AuthenticatorFieldTypeKey => { if (isValidMachineFieldType(type)) { @@ -176,3 +184,38 @@ export function getRouteTypedFields({ return isVerifyUserRoute ? radioFields : getTypedFields(fields); } + +/** + * + * @param {TextFieldOptionsType} field text field type + * @param {string | undefined} value text field value + * @param {string[]} stateValidations validation errors array from state machine + * @returns {string[]} field errors array + */ +export const runFieldValidation = ( + field: TextFieldOptionsType, + value: string | undefined, + stateValidations: ValidationError | undefined +): string[] => { + const fieldErrors: string[] = []; + if (field.required && !value) { + fieldErrors.push(getRequiredFieldText()); + } + if (field.type === 'email') { + if (!isValidEmail(value)) { + fieldErrors.push(getInvalidEmailText()); + } + } + + // add state machine validation errors, if any + const stateFieldValidation = stateValidations?.[field.name]; + if (stateFieldValidation) { + if (isString(stateFieldValidation)) { + fieldErrors.push(stateFieldValidation); + } else { + return fieldErrors.concat(stateFieldValidation); + } + } + + return fieldErrors; +}; diff --git a/packages/ui/src/helpers/authenticator/__tests__/utils.test.ts b/packages/ui/src/helpers/authenticator/__tests__/utils.test.ts index 58926ef8a96..dc5f4478563 100644 --- a/packages/ui/src/helpers/authenticator/__tests__/utils.test.ts +++ b/packages/ui/src/helpers/authenticator/__tests__/utils.test.ts @@ -1,4 +1,9 @@ -import { configureComponent, getTotpCodeURL, trimValues } from '../utils'; +import { + configureComponent, + getTotpCodeURL, + isValidEmail, + trimValues, +} from '../utils'; import * as AuthModule from '@aws-amplify/auth'; @@ -74,3 +79,24 @@ describe('configureComponent', () => { ); }); }); + +describe('isValidEmail', () => { + it('should return true for a valid email address', () => { + expect(isValidEmail('test@example.com')).toBe(true); + expect(isValidEmail('TEST@EXAMPLE.COM')).toBe(true); + }); + + it('should return false for an invalid email address', () => { + expect(isValidEmail('testexample.com')).toBe(false); + expect(isValidEmail('test@')).toBe(false); + expect(isValidEmail('test@.')).toBe(false); + expect(isValidEmail('test@example@test.com')).toBe(false); + expect(isValidEmail('test @example.com')).toBe(false); + }); + + it('should return false if there is no email address', () => { + expect(isValidEmail(null)).toBe(false); + expect(isValidEmail(undefined)).toBe(false); + expect(isValidEmail('')).toBe(false); + }); +}); diff --git a/packages/ui/src/helpers/authenticator/constants.ts b/packages/ui/src/helpers/authenticator/constants.ts index cbf79001365..ab370efc9b5 100644 --- a/packages/ui/src/helpers/authenticator/constants.ts +++ b/packages/ui/src/helpers/authenticator/constants.ts @@ -128,3 +128,11 @@ export const ALLOWED_SPECIAL_CHARACTERS = [ ';', '|', '_', '~', '`', '=', '+', '-', ' ' ]; + +/** + * Email validation regex + * + * source: HTML5 spec https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address + */ +export const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; diff --git a/packages/ui/src/helpers/authenticator/textUtil.ts b/packages/ui/src/helpers/authenticator/textUtil.ts index 0a5f4867060..0c4709bea27 100644 --- a/packages/ui/src/helpers/authenticator/textUtil.ts +++ b/packages/ui/src/helpers/authenticator/textUtil.ts @@ -147,4 +147,10 @@ export const authenticatorTextUtil = { getVerifyText: () => translate(DefaultTexts.VERIFY), getVerifyContactText: () => translate(DefaultTexts.VERIFY_CONTACT), getAccountRecoveryInfoText: () => translate(DefaultTexts.VERIFY_HEADING), + + /** Validations */ + // TODO: add defaultText + getInvalidEmailText: () => translate('Please enter a valid email'), + // TODO: add defaultText + getRequiredFieldText: () => translate('This field is required'), } as const; // using `as const` so that keys are strongly typed diff --git a/packages/ui/src/helpers/authenticator/utils.ts b/packages/ui/src/helpers/authenticator/utils.ts index a1ab2a6e40e..41a14497bbc 100644 --- a/packages/ui/src/helpers/authenticator/utils.ts +++ b/packages/ui/src/helpers/authenticator/utils.ts @@ -8,7 +8,7 @@ import { appendToCognitoUserAgent } from '@aws-amplify/auth'; import { waitFor } from 'xstate/lib/waitFor.js'; import { AuthInterpreter, AuthMachineHubHandler } from '../../types'; -import { ALLOWED_SPECIAL_CHARACTERS } from './constants'; +import { ALLOWED_SPECIAL_CHARACTERS, emailRegex } from './constants'; import { getActorState } from './actor'; import { isFunction } from '../../utils'; @@ -180,3 +180,9 @@ export function trimValues>( {} as T ); } + +export const isValidEmail = (value: string | undefined) => { + if (!value) return false; + + return emailRegex.test(value); +};