From d522475103bb7548a59d82c808b2d841b271e63d Mon Sep 17 00:00:00 2001 From: Guillaume Grossetie Date: Mon, 16 Dec 2024 18:29:02 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(front):=20traduit=20et=20simplifie=20l?= =?UTF-8?q?e=20formulaire=20de=20cr=C3=A9ation=20de=20compte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/src/components/Header.jsx | 48 +++++---- front/src/components/Register.jsx | 147 ++++++++++---------------- front/src/helpers/forms.js | 39 +++++++ front/src/helpers/forms.test.js | 87 +++++++++++++++ front/src/helpers/validationEmail.js | 7 -- front/src/locales/en/translation.json | 20 ++++ front/src/locales/fr/translation.json | 20 ++++ 7 files changed, 248 insertions(+), 120 deletions(-) create mode 100644 front/src/helpers/forms.js create mode 100644 front/src/helpers/forms.test.js delete mode 100644 front/src/helpers/validationEmail.js diff --git a/front/src/components/Header.jsx b/front/src/components/Header.jsx index 568dcef1c..e31eb6517 100644 --- a/front/src/components/Header.jsx +++ b/front/src/components/Header.jsx @@ -1,7 +1,8 @@ import React, { useMemo } from 'react' import { LifeBuoy } from 'react-feather' import { useSelector } from 'react-redux' -import { Link, Route, Switch } from 'react-router-dom' +import { NavLink, Route, Switch } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import logoContent from '/images/logo.svg?inline' import { useActiveWorkspace } from '../hooks/workspace.js' @@ -9,7 +10,6 @@ import { useActiveWorkspace } from '../hooks/workspace.js' import styles from './header.module.scss' import LanguagesMenu from './header/LanguagesMenu.jsx' import UserMenu from './header/UserMenu.jsx' -import LanguagesIcon from './header/LanguagesIcon.jsx' function Header() { const activeWorkspace = useActiveWorkspace() @@ -18,6 +18,7 @@ function Header() { [activeWorkspace] ) const connected = useSelector((state) => state.loggedIn) + const { t } = useTranslation() return ( @@ -26,16 +27,16 @@ function Header() {

- + Stylo - +

{connected && ( <> @@ -74,18 +75,25 @@ function Header() { )} {!connected && ( - + <> + + + )}
diff --git a/front/src/components/Register.jsx b/front/src/components/Register.jsx index 7349b5531..dee287847 100644 --- a/front/src/components/Register.jsx +++ b/front/src/components/Register.jsx @@ -1,9 +1,7 @@ -import React, { useState } from 'react' +import React, { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { Link, useHistory } from 'react-router-dom' - -import etv from '../helpers/eventTargetValue' -import validateEmail from '../helpers/validationEmail' - +import { useToasts } from '@geist-ui/core' import { useGraphQL } from '../helpers/graphQL' import * as queries from './Credentials.graphql' @@ -11,143 +9,108 @@ import styles from './login.module.scss' import Field from './Field' import Button from './Button' import { ArrowLeftCircle, Check } from 'react-feather' +import { fromFormData, validateSameFieldValue } from '../helpers/forms.js' -function Register() { +export default function Register() { + const { t } = useTranslation() + const { setToast } = useToasts() + const passwordRef = useRef() + const passwordConfirmationRef = useRef() const history = useHistory() - const [email, setEmail] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [passwordC, setPasswordC] = useState('') - const [displayName, setDisplayName] = useState('') - const [firstName, setFirstName] = useState('') - const [lastName, setLastName] = useState('') - const [institution, setInstitution] = useState('') const runQuery = useGraphQL() - const details = { - email, - username, - password, - passwordC, - displayName, - firstName, - lastName, - institution, - } - - const createUser = async (details) => { - if (details.password !== details.passwordC) { - alert('Password and Password confirm mismatch') - return false - } - if (details.password === '') { - alert('password is empty') - return false - } - if (details.username === '') { - alert('Username is empty') - return false - } - if (details.email === '') { - alert('Email is empty') - return false - } - if (!validateEmail(details.email)) { - alert('Email appears to be malformed') - return false - } + const handleFormSubmit = useCallback(async (event) => { + event.preventDefault() + const details = fromFormData(event.target) try { await runQuery({ query: queries.createUser, variables: { details } }) // if no error thrown, we can navigate to / + setToast({ + type: 'default', + text: t('credentials.register.successToast'), + }) history.push('/') } catch (err) { - console.log('Unable to create a user', err) + setToast({ + type: 'error', + text: t('credentials.register.errorToast', { message: err.message }), + }) } - } + }, []) return (
-
{ - event.preventDefault() - createUser(details) - }} - > -

Create a Stylo account

+ +

{t('credentials.register.title')}

- Required informations + {t('credentials.register.requiredFields')} setEmail(etv(e))} /> setUsername(etv(e))} /> setPassword(etv(e))} /> setPasswordC(etv(e))} - className={password === passwordC ? null : styles.beware} />
- Optional details + {t('credentials.register.optionalFields')} - setDisplayName(etv(e))} - /> - setFirstName(etv(e))} - /> - setLastName(etv(e))} - /> - setInstitution(etv(e))} - /> + + + +
  • - Go back to Login + {t('credentials.login.goBackLink')}
@@ -155,5 +118,3 @@ function Register() {
) } - -export default Register diff --git a/front/src/helpers/forms.js b/front/src/helpers/forms.js new file mode 100644 index 000000000..edc9f7378 --- /dev/null +++ b/front/src/helpers/forms.js @@ -0,0 +1,39 @@ +/** + * Transforms a form into a plain object + * + * formData.entries() does not handle multiple values (sic) and formData.getAll() only works at a field level + * + * @param {React.ReactHTMLElement|FormData} formElement + * @returns {Record} + */ +export function fromFormData(input) { + const d = input instanceof FormData ? input : new FormData(input) + + return Array.from(d.keys()).reduce( + (data, key) => ({ + ...data, + [key.replace('[]', '')]: key.endsWith('[]') ? d.getAll(key) : d.get(key), + }), + {} + ) +} + +/** + * + * @param {React.RefObject} field + * @param {React.RefObject} referenceField + * @param {string} errorMessage + * @returns {(event: React.ChangeEvent) => undefined} + */ +export function validateSameFieldValue(field, referenceField, errorMessage) { + return function onChangeEvent() { + const fieldValue = field.current.value + const referenceFieldValue = referenceField.current.value + + if (fieldValue !== referenceFieldValue) { + field.current.setCustomValidity(errorMessage) + } else { + field.current.setCustomValidity('') + } + } +} diff --git a/front/src/helpers/forms.test.js b/front/src/helpers/forms.test.js new file mode 100644 index 000000000..69e595313 --- /dev/null +++ b/front/src/helpers/forms.test.js @@ -0,0 +1,87 @@ +import { describe, test, expect } from 'vitest' +import { fromFormData, validateSameFieldValue } from './forms.js' + +describe('fromFormData()', () => { + test('with simple values', () => { + const d = new FormData() + d.append('un', 1) + d.append('deux', 'deux') + expect(fromFormData(d)).toEqual({ un: '1', deux: 'deux' }) + }) + + test('with checkboxes values', () => { + const d = new FormData() + d.append('un', 'un') + d.append('un', 'deux') + d.append('tags[]', 'un') + d.append('tags[]', 'deux') + expect(fromFormData(d)).toEqual({ un: 'un', tags: ['un', 'deux'] }) + }) +}) + +describe('validateSameFieldValue()', () => { + function createPassword(name) { + const el = document.createElement('input') + el.type = 'password' + el.name = name + el.required = true + return { + get current() { + return el + }, + } + } + + test('built-in validation is triggered when no value', () => { + const [fieldA, fieldB] = [createPassword('a'), createPassword('b')] + + validateSameFieldValue(fieldA, fieldB, 'mismatch!')() + expect(fieldA.current.validity.valid).toBe(false) + expect(fieldA.current.validationMessage).toBe('Constraints not satisfied') + expect(fieldA.current.validity.valueMissing).toBe(true) + }) + + test('works with same values', () => { + const [fieldA, fieldB] = [createPassword('a'), createPassword('b')] + fieldA.current.value = '@' + fieldB.current.value = '@' + + validateSameFieldValue(fieldA, fieldB, 'mismatch!')() + expect(fieldA.current.validity.valid).toBe(true) + expect(fieldA.current.validationMessage).toBe('') + expect(fieldA.current.validity.valueMissing).toBe(false) + }) + + test('fails when reference field is not set', () => { + const [fieldA, fieldB] = [createPassword('a'), createPassword('b')] + fieldA.current.value = '' + fieldB.current.value = '@' + + validateSameFieldValue(fieldA, fieldB, 'mismatch!')() + expect(fieldA.current.validity.valid).toBe(false) + expect(fieldA.current.validationMessage).toBe('mismatch!') + expect(fieldA.current.validity.valueMissing).toBe(true) + }) + + test('fails when reference field is set but does not validate', () => { + const [fieldA, fieldB] = [createPassword('a'), createPassword('b')] + fieldA.current.value = '@' + fieldB.current.value = '' + + validateSameFieldValue(fieldA, fieldB, 'mismatch!')() + expect(fieldA.current.validity.valid).toBe(false) + expect(fieldA.current.validationMessage).toBe('mismatch!') + expect(fieldA.current.validity.valueMissing).toBe(false) + }) + + test('fails when field value mismatches reference field', () => { + const [fieldA, fieldB] = [createPassword('a'), createPassword('b')] + fieldA.current.value = '@@' + fieldB.current.value = '@' + + validateSameFieldValue(fieldA, fieldB, 'mismatch!')() + expect(fieldA.current.validity.valid).toBe(false) + expect(fieldA.current.validationMessage).toBe('mismatch!') + expect(fieldA.current.validity.valueMissing).toBe(false) + }) +}) diff --git a/front/src/helpers/validationEmail.js b/front/src/helpers/validationEmail.js deleted file mode 100644 index c2b423c32..000000000 --- a/front/src/helpers/validationEmail.js +++ /dev/null @@ -1,7 +0,0 @@ -const validateEmail = (email) => { - // eslint-disable-next-line - const re = - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ - return re.test(String(email).toLowerCase()) -} -export default validateEmail diff --git a/front/src/locales/en/translation.json b/front/src/locales/en/translation.json index 7eb3e6d87..564e3e7d1 100644 --- a/front/src/locales/en/translation.json +++ b/front/src/locales/en/translation.json @@ -3,6 +3,7 @@ "user.account.displayName": "Display name", "user.account.firstName": "First name", "user.account.lastName": "Last name", + "user.account.username": "Username", "user.account.institution": "Institution", "user.account.id": "Identifier", "user.account.email": "Email", @@ -12,6 +13,22 @@ "user.account.authentication": "Authentication type", "user.account.copyApiKey": "Copy API key value to clipboard", "user.account.apiKeyValue": "API key value is: {{token}}", + "credentials.manage": "Manage my account", + "credentials.register.title": "Create a local Stylo account", + "credentials.register.requiredFields": "Required informations", + "credentials.register.successToast": "Account created. You can now login.", + "credentials.register.errorToast": "Unable to create the account ({{ message }}).", + "credentials.register.optionalFields": "Optional details", + "credentials.login.withRemoteAccount": "Login with an external account", + "credentials.login.withLocalAccount": "Login with a local account", + "credentials.login.withService": "Login with {{ name }}", + "credentials.login.registerWithService": "Create a {{ name }} account", + "credentials.login.recommendedMethod": "recommended", + "credentials.login.howto": "How does it work?", + "credentials.login.confirmButton": "Login", + "credentials.login.registerLink": "Create an account", + "credentials.login.goBackLink": "Go back to Login", + "credentials.logout.confirmButton": "Logout", "credentials.updatePassword.updatingButton": "Updating…", "credentials.updatePassword.confirmButton": "Update", "workspace.myspace": "My space", @@ -136,6 +153,7 @@ "credentials.changePassword.title": "Change password", "credentials.changePassword.para": "This section is strictly private, changing your password will only affect your combination of username/email and password.", "credentials.oldPassword.placeholder": "Old password", + "credentials.password.placeholder": "Password", "credentials.newPassword.placeholder": "New password", "credentials.confirmNewPassword.placeholder": "Confirm new password", "export.format.label": "Formats", @@ -146,6 +164,8 @@ "export.bookDivision.part": "Book division: Part & chapters", "export.bookDivision.chapter": "Book division: Chapter only", "export.submitForm.button": "Export with these settings", + "header.manage": "Manage my account and workpaces", + "header.languages": "Change interface language", "footer.documentation.link": "Documentation", "footer.changelog.link": "Changelog", "footer.navStats.checkbox": "I accept to share my navigation stats", diff --git a/front/src/locales/fr/translation.json b/front/src/locales/fr/translation.json index 50ea66268..c9462d43a 100644 --- a/front/src/locales/fr/translation.json +++ b/front/src/locales/fr/translation.json @@ -3,6 +3,7 @@ "user.account.displayName": "Nom d'affichage", "user.account.firstName": "Prénom", "user.account.lastName": "Nom", + "user.account.username": "Identifiant de compte", "user.account.institution": "Institution", "user.account.id": "Identifiant", "user.account.email": "Courriel", @@ -12,6 +13,22 @@ "user.account.authentication": "Type d'authentification", "user.account.copyApiKey": "Copier la clé d'accès à l'API dans le presse-papier", "user.account.apiKeyValue": "La valeur de la clé d'API est : {{token}}", + "credentials.manage": "Gérer mon compte", + "credentials.register.title": "Créer un compte Stylo local", + "credentials.register.successToast": "Compte créé. Vous pouvez vous connecter dès à présent.", + "credentials.register.errorToast": "Erreur lors de la création du compte ({{ message }}).", + "credentials.register.requiredFields": "Champs obligatoires", + "credentials.register.optionalFields": "Informations optionnelles", + "credentials.login.withRemoteAccount": "Connexion avec un compte externe", + "credentials.login.withLocalAccount": "Connexion avec un compte local", + "credentials.login.withService": "Se connecter avec {{ name }}", + "credentials.login.registerWithService": "Créer un compte {{ name }}", + "credentials.login.recommendedMethod": "recommandé", + "credentials.login.howto": "Comment ça fonctionne ?", + "credentials.login.confirmButton": "Connexion", + "credentials.login.registerLink": "Créer un compte", + "credentials.login.goBackLink": "Retour à l'écran d'accueil", + "credentials.logout.confirmButton": "Se déconnecter", "credentials.updatePassword.updatingButton": "Mise à jour…", "credentials.updatePassword.confirmButton": "Mettre à jour", "workspace.myspace": "Mon espace", @@ -136,6 +153,7 @@ "credentials.changePassword.title": "Changer le mot de passe", "credentials.changePassword.para": "Cette section est strictement privée, la modification de votre mot de passe n'affectera que votre combinaison nom d'utilisateur/email et mot de passe.", "credentials.oldPassword.placeholder": "Ancien mot de passe", + "credentials.password.placeholder": "Mot de passe", "credentials.newPassword.placeholder": "Nouveau mot de passe", "credentials.confirmNewPassword.placeholder": "Confirmer le nouveau mot de passe", "export.format.label": "Formats", @@ -146,6 +164,8 @@ "export.bookDivision.part": "Division du corpus : parties et chapitres", "export.bookDivision.chapter": "Division du corpus : chapitres seulement", "export.submitForm.button": "Exporter avec ces paramètres", + "header.manage": "Gérer mon compte et mes espaces de travail", + "header.languages": "Modifier la langue de l'interface", "footer.documentation.link": "Documentation", "footer.changelog.link": "Journal des modifications", "footer.navStats.checkbox": "J'accepte de partager mes statistiques de navigation", From 80993a1b91f56009d2fa1453cb7c7a3706e7277c Mon Sep 17 00:00:00 2001 From: Thomas Parisot Date: Fri, 13 Dec 2024 13:00:00 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20suppression=20de=20la=20mention=20?= =?UTF-8?q?=C3=A0=20react-helmet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react@19 prendra en charge ces balises, en plus --- front/index.html | 83 +++++++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 30 deletions(-) diff --git a/front/index.html b/front/index.html index b575f2061..e771bca48 100644 --- a/front/index.html +++ b/front/index.html @@ -1,45 +1,68 @@ - - - - - - Stylo - - - - - - - - - - - + + + + + + Stylo + + + + + + + + + + + {{#if SNOWPACK_MATOMO}} {{/if}} -
+