From b60b2ffcb60ac354989e151832a90fb24319bcfc Mon Sep 17 00:00:00 2001 From: gab-gil Date: Mon, 16 Dec 2024 14:27:58 -0500 Subject: [PATCH 1/4] refactor: change register form to react hook form --- canopeum_backend/pyproject.toml | 4 + canopeum_frontend/.prettierignore | 1 + canopeum_frontend/package-lock.json | 17 ++ canopeum_frontend/package.json | 1 + canopeum_frontend/src/pages/Register.tsx | 298 ++++++++-------------- canopeum_frontend/src/utils/validators.ts | 2 +- 6 files changed, 131 insertions(+), 192 deletions(-) create mode 100644 canopeum_frontend/.prettierignore diff --git a/canopeum_backend/pyproject.toml b/canopeum_backend/pyproject.toml index 3da267d96..2b246e7e7 100644 --- a/canopeum_backend/pyproject.toml +++ b/canopeum_backend/pyproject.toml @@ -34,6 +34,10 @@ dev = [ "types-jsonschema", ] +[tool.pyright] +venvPath = "." +venv = ".venv" + # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] show_column_numbers = true diff --git a/canopeum_frontend/.prettierignore b/canopeum_frontend/.prettierignore new file mode 100644 index 000000000..1d085cacc --- /dev/null +++ b/canopeum_frontend/.prettierignore @@ -0,0 +1 @@ +** diff --git a/canopeum_frontend/package-lock.json b/canopeum_frontend/package-lock.json index c1b88b6df..5650df361 100644 --- a/canopeum_frontend/package-lock.json +++ b/canopeum_frontend/package-lock.json @@ -23,6 +23,7 @@ "nswag": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.54.1", "react-i18next": "^14.1.0", "react-map-gl": "^7.1.7", "react-router-dom": "^6.22.3", @@ -12464,6 +12465,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.54.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.1.tgz", + "integrity": "sha512-PUNzFwQeQ5oHiiTUO7GO/EJXGEtuun2Y1A59rLnZBBj+vNEOWt/3ERTiG1/zt7dVeJEM+4vDX/7XQ/qanuvPMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-i18next": { "version": "14.1.3", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz", diff --git a/canopeum_frontend/package.json b/canopeum_frontend/package.json index 731e693f2..4fbfac001 100644 --- a/canopeum_frontend/package.json +++ b/canopeum_frontend/package.json @@ -30,6 +30,7 @@ "nswag": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.54.1", "react-i18next": "^14.1.0", "react-map-gl": "^7.1.7", "react-router-dom": "^6.22.3", diff --git a/canopeum_frontend/src/pages/Register.tsx b/canopeum_frontend/src/pages/Register.tsx index 9b6ec87bc..f67847d3b 100644 --- a/canopeum_frontend/src/pages/Register.tsx +++ b/canopeum_frontend/src/pages/Register.tsx @@ -1,4 +1,6 @@ +/* eslint-disable react/jsx-props-no-spreading -- Good practice for React Hook Form */ import { useCallback, useContext, useEffect, useState } from 'react' +import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { Link, useSearchParams } from 'react-router-dom' @@ -9,7 +11,14 @@ import useApiClient from '@hooks/ApiClientHook' import type { UserInvitation } from '@services/api' import { RegisterUser } from '@services/api' import { storeToken } from '@utils/auth.utils' -import { type InputValidationError, isValidEmail, isValidPassword, mustMatch } from '@utils/validators' +import { passwordRegex } from '@utils/validators' + +type RegisterInputs = { + username: string, + email: string, + password: string, + confirmPassword: string, +} const Register = () => { const [searchParams, _setSearchParams] = useSearchParams() @@ -17,144 +26,26 @@ const Register = () => { const { t: translate } = useTranslation() const { getApiClient } = useApiClient() - const [username, setUsername] = useState('') - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [passwordConfirmation, setPasswordConfirmation] = useState('') - - const [usernameError, setUsernameError] = useState() - const [emailError, setEmailError] = useState() - const [passwordError, setPasswordError] = useState() - const [passwordConfirmationError, setPasswordConfirmationError] = useState< - InputValidationError | undefined - >() - const [registrationError, setRegistrationError] = useState() const [codeInvalid, setCodeInvalid] = useState(false) const [codeExpired, setCodeExpired] = useState(false) const [userInvitation, setUserInvitation] = useState() - const fetchUserInvitation = useCallback(async (code: string) => { - try { - const userInvitationResponse = await getApiClient().userInvitationClient.detail(code) - if (userInvitationResponse.expiresAt <= new Date()) { - setCodeExpired(true) - setCodeInvalid(false) - setUserInvitation(undefined) - - return - } - - setCodeExpired(false) - setCodeInvalid(false) - setUserInvitation(userInvitationResponse) - setEmail(userInvitationResponse.email) - } catch { - setCodeInvalid(true) - setCodeExpired(false) - setUserInvitation(undefined) - } - }, [getApiClient]) - - useEffect(() => { - const code = searchParams.get('code') - if (!code) return - - void fetchUserInvitation(code) - }, [searchParams, fetchUserInvitation]) - - const validateUsername = () => { - if (!username) { - setUsernameError('required') - - return false - } - - setUsernameError(undefined) - - return true - } - - const validateEmail = () => { - if (!email) { - setEmailError('required') - - return false - } - - if (!isValidEmail(email)) { - setEmailError('email') - - return false - } - - setEmailError(undefined) - - return true - } - - const validatePassword = () => { - if (!password) { - setPasswordError('required') - - return false - } - - if (!isValidPassword(password)) { - setPasswordError('password') - - return false - } - - setPasswordError(undefined) - - return true - } - - const validatePasswordConfirmation = () => { - if (!passwordConfirmation) { - setPasswordConfirmationError('required') - - return false - } - - if (!mustMatch(password, passwordConfirmation)) { - setPasswordConfirmationError('mustMatch') - - return false - } - - setPasswordConfirmationError(undefined) - - return true - } - - const validateForm = () => { - // Do not return directly the method calls; - // we need each of them to be called before returning the result - const usernameValid = validateUsername() - const emailValid = validateEmail() - const passwordValid = validatePassword() - const passwordConfirmationValid = validatePasswordConfirmation() - - return usernameValid - && emailValid - && passwordValid - && passwordConfirmationValid - } - - const onCreateAccountClick = async () => { - const isFormValid = validateForm() - if (!isFormValid) return - + const { + register, + handleSubmit, + formState: { errors, touchedFields }, + setValue, + } = useForm({ mode: 'onTouched' }) + const onSubmit: SubmitHandler = async data => { try { const response = await getApiClient().authenticationClient.register( new RegisterUser({ - email: email.trim(), - username: username.trim(), - password, - passwordConfirmation, + email: data.email.trim(), + username: data.username.trim(), + password: data.password, + passwordConfirmation: data.confirmPassword, code: userInvitation?.code, }), ) @@ -169,6 +60,40 @@ const Register = () => { } } + const invalidFieldClass = 'is-invalid' + + const fetchUserInvitation = useCallback( + async (code: string) => { + try { + const userInvitationResponse = await getApiClient().userInvitationClient.detail(code) + if (userInvitationResponse.expiresAt <= new Date()) { + setCodeExpired(true) + setCodeInvalid(false) + setUserInvitation(undefined) + + return + } + + setCodeExpired(false) + setCodeInvalid(false) + setUserInvitation(userInvitationResponse) + setValue('email', userInvitationResponse.email) + } catch { + setCodeInvalid(true) + setCodeExpired(false) + setUserInvitation(undefined) + } + }, + [getApiClient], + ) + + useEffect(() => { + const code = searchParams.get('code') + if (!code) return + + void fetchUserInvitation(code) + }, [searchParams, fetchUserInvitation]) + return ( <> @@ -176,109 +101,109 @@ const Register = () => {

{translate('auth.sign-up-header-text')}

-
+
- + validateUsername()} - onChange={event => setUsername(event.target.value)} - type='text' + {...register('username', { + required: { value: true, message: translate('auth.username-error-required') }, + })} /> - {usernameError && ( + {errors.username && ( - {translate('auth.username-error-required')} + {errors.username.message} )}
-
- + validateEmail()} - onChange={event => setEmail(event.target.value)} + id='email' type='email' - value={email} + {...register('email', { + required: { value: true, message: translate('auth.email-error-required') }, + })} /> - {emailError === 'required' && ( - - {translate('auth.email-error-required')} - - )} - {emailError === 'email' && ( + {errors.email && ( - {translate('auth.email-error-format')} + {errors.email.message} )}
-
validatePassword()} - onChange={event => setPassword(event.target.value)} type='password' + {...register('password', { + required: { value: true, message: translate('auth.password-error-required') }, + pattern: { value: passwordRegex, message: translate('auth.password-error-format') }, + })} /> - {passwordError === 'required' && ( + {errors.password && ( - {translate('auth.password-error-required')} - - )} - {passwordError === 'password' && ( - - {translate('auth.password-error-format')} + {errors.password.message} )}
-
validatePasswordConfirmation()} - onChange={event => setPasswordConfirmation(event.target.value)} type='password' + {...register('confirmPassword', { + required: { + value: true, + message: translate('auth.password-confirmation-error-required'), + }, + validate: { + mustMatch: (value, formValues) => + value === formValues.password + || translate('auth.password-error-must-match'), + }, + })} /> - {passwordConfirmationError === 'required' && ( + {errors.confirmPassword && ( - {translate('auth.password-confirmation-error-required')} - - )} - {passwordConfirmationError === 'mustMatch' && ( - - {translate('auth.password-error-must-match')} + {errors.confirmPassword.message} )}
+ {registrationError && {registrationError}} {codeInvalid && ( @@ -288,15 +213,6 @@ const Register = () => { {translate('auth.invitation-expired')} )} - -
{translate('auth.already-have-an-account')} @@ -305,7 +221,7 @@ const Register = () => {
-
+
) diff --git a/canopeum_frontend/src/utils/validators.ts b/canopeum_frontend/src/utils/validators.ts index b01b7280a..2c8f565f7 100644 --- a/canopeum_frontend/src/utils/validators.ts +++ b/canopeum_frontend/src/utils/validators.ts @@ -1,4 +1,4 @@ -const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[\d!#$%&*?@A-Za-z]{8,}$/u +export const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[\d!#$%&*.?@A-Za-z]{8,}$/u export const isValidPassword = (input: string) => new RegExp(passwordRegex).test(input) From a8b68d42dfa234c35ef2b35d360e1579737ff7a8 Mon Sep 17 00:00:00 2001 From: gab-gil Date: Tue, 17 Dec 2024 10:45:52 -0500 Subject: [PATCH 2/4] fix: pr --- canopeum_backend/pyproject.toml | 4 ---- canopeum_frontend/src/constants/style.ts | 5 ++++ canopeum_frontend/src/pages/Register.tsx | 28 +++++++++++------------ canopeum_frontend/src/utils/validators.ts | 4 ++-- 4 files changed, 21 insertions(+), 20 deletions(-) create mode 100644 canopeum_frontend/src/constants/style.ts diff --git a/canopeum_backend/pyproject.toml b/canopeum_backend/pyproject.toml index 2b246e7e7..3da267d96 100644 --- a/canopeum_backend/pyproject.toml +++ b/canopeum_backend/pyproject.toml @@ -34,10 +34,6 @@ dev = [ "types-jsonschema", ] -[tool.pyright] -venvPath = "." -venv = ".venv" - # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] show_column_numbers = true diff --git a/canopeum_frontend/src/constants/style.ts b/canopeum_frontend/src/constants/style.ts new file mode 100644 index 000000000..f62d36719 --- /dev/null +++ b/canopeum_frontend/src/constants/style.ts @@ -0,0 +1,5 @@ +const invalidFieldClass = 'is-invalid' + +export const formClasses = { + invalidFieldClass +} diff --git a/canopeum_frontend/src/pages/Register.tsx b/canopeum_frontend/src/pages/Register.tsx index f67847d3b..0414a4631 100644 --- a/canopeum_frontend/src/pages/Register.tsx +++ b/canopeum_frontend/src/pages/Register.tsx @@ -7,13 +7,14 @@ import { Link, useSearchParams } from 'react-router-dom' import AuthPageLayout from '@components/auth/AuthPageLayout' import { AuthenticationContext } from '@components/context/AuthenticationContext' import { appRoutes } from '@constants/routes.constant' +import { formClasses } from '@constants/style' import useApiClient from '@hooks/ApiClientHook' import type { UserInvitation } from '@services/api' import { RegisterUser } from '@services/api' import { storeToken } from '@utils/auth.utils' -import { passwordRegex } from '@utils/validators' +import { emailRegex, passwordRegex } from '@utils/validators' -type RegisterInputs = { +type RegisterFormInputs = { username: string, email: string, password: string, @@ -37,15 +38,15 @@ const Register = () => { handleSubmit, formState: { errors, touchedFields }, setValue, - } = useForm({ mode: 'onTouched' }) - const onSubmit: SubmitHandler = async data => { + } = useForm({ mode: 'onTouched' }) + const onSubmit: SubmitHandler = async formData => { try { const response = await getApiClient().authenticationClient.register( new RegisterUser({ - email: data.email.trim(), - username: data.username.trim(), - password: data.password, - passwordConfirmation: data.confirmPassword, + email: formData.email.trim(), + username: formData.username.trim(), + password: formData.password, + passwordConfirmation: formData.confirmPassword, code: userInvitation?.code, }), ) @@ -60,8 +61,6 @@ const Register = () => { } } - const invalidFieldClass = 'is-invalid' - const fetchUserInvitation = useCallback( async (code: string) => { try { @@ -111,7 +110,7 @@ const Register = () => { aria-describedby='emailHelp' className={`form-control ${ touchedFields.username && errors.username - ? invalidFieldClass + ? formClasses.invalidFieldClass : '' }`} {...register('username', { @@ -130,7 +129,7 @@ const Register = () => { aria-describedby='email' className={`form-control ${ touchedFields.email && errors.email - ? invalidFieldClass + ? formClasses.invalidFieldClass : '' }`} disabled={!!userInvitation} @@ -138,6 +137,7 @@ const Register = () => { type='email' {...register('email', { required: { value: true, message: translate('auth.email-error-required') }, + pattern: {value: emailRegex, message: translate('auth.email-error-format')} })} /> {errors.email && ( @@ -151,7 +151,7 @@ const Register = () => { { new RegExp(passwordRegex).test(input) -const emailRegex = /^[^@]+@[^@][^.@]*\.[^@]+$/u +export const emailRegex = /^[^@]+@[^@][^.@]*\.[^@]+$/u export const isValidEmail = (input: string) => new RegExp(emailRegex).test(input) From db8f205d1bb7832cca83aeea314f0d93941e3c65 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:47:17 +0000 Subject: [PATCH 3/4] Commit from GitHub Actions (Frontend PR validation) --- canopeum_frontend/src/constants/style.ts | 2 +- canopeum_frontend/src/pages/Register.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/canopeum_frontend/src/constants/style.ts b/canopeum_frontend/src/constants/style.ts index f62d36719..2012593fd 100644 --- a/canopeum_frontend/src/constants/style.ts +++ b/canopeum_frontend/src/constants/style.ts @@ -1,5 +1,5 @@ const invalidFieldClass = 'is-invalid' export const formClasses = { - invalidFieldClass + invalidFieldClass, } diff --git a/canopeum_frontend/src/pages/Register.tsx b/canopeum_frontend/src/pages/Register.tsx index 0414a4631..43170fae6 100644 --- a/canopeum_frontend/src/pages/Register.tsx +++ b/canopeum_frontend/src/pages/Register.tsx @@ -137,7 +137,7 @@ const Register = () => { type='email' {...register('email', { required: { value: true, message: translate('auth.email-error-required') }, - pattern: {value: emailRegex, message: translate('auth.email-error-format')} + pattern: { value: emailRegex, message: translate('auth.email-error-format') }, })} /> {errors.email && ( From 3446d7d4f5a91a3b449ebbf7fbdba40edb20e789 Mon Sep 17 00:00:00 2001 From: Samuel Therrien Date: Tue, 17 Dec 2024 12:40:25 -0500 Subject: [PATCH 4/4] Delete .prettierignore --- canopeum_frontend/.prettierignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 canopeum_frontend/.prettierignore diff --git a/canopeum_frontend/.prettierignore b/canopeum_frontend/.prettierignore deleted file mode 100644 index 1d085cacc..000000000 --- a/canopeum_frontend/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -**