diff --git a/src/components/commun/Datepicker.jsx b/src/components/commun/Datepicker.jsx index f13d28a..9160c39 100644 --- a/src/components/commun/Datepicker.jsx +++ b/src/components/commun/Datepicker.jsx @@ -1,13 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -export default function Datepicker({ children, id, isRequired = true, onChange, min }) { +export default function Datepicker({ children, id, isRequired = true, onChange, min, error }) { return ( -
+
+ {error && ( +

+ {error} +

+ )}
); } @@ -19,4 +24,5 @@ Datepicker.propTypes = { onChange: PropTypes.func, min: PropTypes.string, name: PropTypes.string, + error: PropTypes.string }; diff --git a/src/components/commun/Input.jsx b/src/components/commun/Input.jsx index e972c23..8a11693 100644 --- a/src/components/commun/Input.jsx +++ b/src/components/commun/Input.jsx @@ -1,11 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; -export default function Input({ children, id, isRequired = true, autoComplete = 'on', testId = '', type = 'text', - pattern, onChange, list, min, disabled, isLoading, ariaBusy, value, maxlength }) { +export default function Input({ + children, + id, + isRequired = true, + autoComplete = 'on', + testId = '', + type = 'text', + pattern, + onChange, + list, + min, + disabled, + isLoading, + ariaBusy, + value, + maxlength, + error, +}) { return (
-
+
)} + {error && ( +

+ {error} +

+ )}
); @@ -49,5 +70,6 @@ Input.propTypes = { ariaBusy: PropTypes.bool, value: PropTypes.string, testId: PropTypes.string, - maxlength: PropTypes.string + maxlength: PropTypes.string, + error: PropTypes.string }; diff --git a/src/components/commun/ZoneDeTexte.jsx b/src/components/commun/ZoneDeTexte.jsx index 5c6bfc9..9f5caf8 100644 --- a/src/components/commun/ZoneDeTexte.jsx +++ b/src/components/commun/ZoneDeTexte.jsx @@ -1,13 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -export default function ZoneDeTexte({ children, id, isRequired = true, maxlength = "2500" }) { +export default function ZoneDeTexte({ children, id, isRequired = true, maxlength = '2500', error }) { return ( -
+
+ {error && ( +

+ {error} +

+ )}
); } @@ -17,4 +22,5 @@ ZoneDeTexte.propTypes = { id: PropTypes.string, isRequired: PropTypes.bool, maxlength: PropTypes.string, + error: PropTypes.string, }; diff --git a/src/shared/checkValidity.js b/src/shared/checkValidity.js new file mode 100644 index 0000000..9cc88e0 --- /dev/null +++ b/src/shared/checkValidity.js @@ -0,0 +1,9 @@ +export const checkValidity = (ref, setErrors) => { + const formData = new FormData(ref.current); + const keys = Array.from(formData.keys()); + const formElements = keys.map(key => document.getElementById(key)).filter(key => key !== null); + const errors = formElements.map(formElement => ({ + [formElement.id]: formElement.validationMessage, + })); + setErrors(Object.assign({}, ...errors)); +}; diff --git a/src/views/candidature-conseiller/AddressChooser.jsx b/src/views/candidature-conseiller/AddressChooser.jsx index 9594c76..aaf03db 100644 --- a/src/views/candidature-conseiller/AddressChooser.jsx +++ b/src/views/candidature-conseiller/AddressChooser.jsx @@ -2,17 +2,19 @@ import React, { useState } from 'react'; import Input from '../../components/commun/Input'; import { useGeoApi } from './useGeoApi'; import { debounce } from './debounce'; +import PropTypes from 'prop-types'; -export default function AddressChooser() { +export default function AddressChooser({ error }) { const { searchByName, villes } = useGeoApi(); const [codeCommune, setCodeCommune] = useState(''); return ( <> { if (event.target.value.length >= 3) { searchByName(event.target.value); @@ -25,7 +27,7 @@ export default function AddressChooser() { Votre lieu d’habitation *{' '} Saississez le nom ou le code postal de votre commune. - + {villes?.map(({ codesPostaux, nom }, key) => (
} -
- - - - + checkValidity(ref, setErrors)} + ref={ref}> + + + +
- diff --git a/src/views/candidature-coordinateur/CandidatureCoordinateur.test.jsx b/src/views/candidature-coordinateur/CandidatureCoordinateur.test.jsx index 3768b26..05b1c30 100644 --- a/src/views/candidature-coordinateur/CandidatureCoordinateur.test.jsx +++ b/src/views/candidature-coordinateur/CandidatureCoordinateur.test.jsx @@ -539,5 +539,74 @@ describe('candidature coordinateur', () => { vi.useRealTimers(); }); + + it.each([ + { + description: 'un SIRET/RIDET', + selector: 'SIRET / RIDET * Format attendu : SIRET (12345678901234) ou RIDET (123456789)', + message: 'Veuillez renseigner le SIRET/RIDET' + }, + { + description: 'une dénomination', + selector: 'Dénomination *', + message: 'Veuillez renseigner la dénomination' + }, + { + description: 'un prénom', + selector: 'Prénom *', + message: 'Veuillez renseigner ce prénom' + }, + { + description: 'un nom', + selector: 'Nom *', + message: 'Veuillez renseigner le nom' + }, + { + description: 'une fonction', + selector: 'Fonction *', + message: 'Veuillez renseigner la fonction' + }, + { + description: 'un email', + selector: 'Adresse électronique * Format attendu : nom@domaine.fr', + message: 'Veuillez renseigner le mail' + }, + { + description: 'un téléphone', + selector: 'Téléphone * Format attendu : 0122334455 ou +33122334455', + message: 'Veuillez renseigner le téléphone' + }, + { + description: 'une date', + selector: 'Choisir une date', + message: 'Veuillez renseigner la date' + }, + { + description: 'une motivation', + selector: 'Votre message * Limité à 2500 caractères', + message: 'Veuillez renseigner la motivation' + }, + ])('quand je valide le formulaire avec $description vide alors j’ai un message d’erreur ', async ({ selector, message }) => { + // GIVEN + vi.stubGlobal('turnstile', { + reset: vi.fn(), + remove: vi.fn(), + render: vi.fn() + }); + render(); + const champDeFormulaire = screen.getByLabelText(selector); + Object.defineProperty(champDeFormulaire, 'validationMessage', { + value: message, + configurable: true, + }); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + fireEvent.click(envoyer); + + // THEN + const contenuErreurValidation = await screen.findByText(message, { selector: 'p' }); + expect(contenuErreurValidation).toBeInTheDocument(); + }); }); diff --git a/src/views/candidature-coordinateur/Motivation.jsx b/src/views/candidature-coordinateur/Motivation.jsx index b152551..b26eb44 100644 --- a/src/views/candidature-coordinateur/Motivation.jsx +++ b/src/views/candidature-coordinateur/Motivation.jsx @@ -1,7 +1,8 @@ import React from 'react'; import ZoneDeTexte from '../../components/commun/ZoneDeTexte'; +import PropTypes from 'prop-types'; -export default function Motivation() { +export default function Motivation({ errors }) { return (
Votre motivation @@ -9,7 +10,7 @@ export default function Motivation() { En quelques lignes, décrivez le motif de votre besoin en recrutement.{' '} Indiquez les actions prévues, la justification du poste, ainsi que le public ciblé.

- + Votre message * Limité à 2500 caractères
    @@ -29,3 +30,7 @@ export default function Motivation() {
); } + +Motivation.propTypes = { + errors: PropTypes.object +}; diff --git a/src/views/candidature-structure/BesoinEnConseillerNumerique.jsx b/src/views/candidature-structure/BesoinEnConseillerNumerique.jsx index ecbd75e..a16ff74 100644 --- a/src/views/candidature-structure/BesoinEnConseillerNumerique.jsx +++ b/src/views/candidature-structure/BesoinEnConseillerNumerique.jsx @@ -3,15 +3,16 @@ import React from 'react'; import BoutonRadio from '../../components/commun/BoutonRadio'; import Datepicker from '../../components/commun/Datepicker'; import Input from '../../components/commun/Input'; +import PropTypes from 'prop-types'; -export default function BesoinEnConseillerNumerique() { +export default function BesoinEnConseillerNumerique({ errors }) { const dateDuJour = new Date().toISOString().slice(0, 10); return (
Votre besoin en conseiller(s) numérique(s)
- + Combien de conseillers numériques souhaitez-vous accueillir ? *
@@ -25,9 +26,13 @@ export default function BesoinEnConseillerNumerique() {

À partir de quand êtes vous prêt à accueillir votre conseiller numerique ? *

- + Choisir une date
); } + +BesoinEnConseillerNumerique.propTypes = { + errors: PropTypes.object +}; diff --git a/src/views/candidature-structure/CandidatureStructure.jsx b/src/views/candidature-structure/CandidatureStructure.jsx index 4f5f70b..b95edfa 100644 --- a/src/views/candidature-structure/CandidatureStructure.jsx +++ b/src/views/candidature-structure/CandidatureStructure.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import SommaireStructure from './SommaireStructure'; import InformationsDeContact from './InformationsDeContact'; import InformationsDeStructure from './InformationsDeStructure'; @@ -20,14 +20,17 @@ import '@gouvfr/dsfr/dist/component/notice/notice.min.css'; import '@gouvfr/dsfr/dist/component/sidemenu/sidemenu.min.css'; import '@gouvfr/dsfr/dist/component/alert/alert.min.css'; import '../candidature-conseiller/CandidatureConseiller.css'; +import { checkValidity } from '../../shared/checkValidity'; export default function CandidatureStructure() { const [geoLocation, setGeoLocation] = useState(null); const [codeCommune, setCodeCommune] = useState(''); const [widgetId, setWidgetId] = useState(null); const [validationError, setValidationError] = useState(''); + const [errors, setErrors] = useState({}); const navigate = useNavigate(); const { buildStructureData, creerCandidatureStructure } = useApiAdmin(); + const ref = useRef(null); useEffect(() => { document.title = 'Conseiller numérique - Engager un conseiller numérique'; @@ -71,16 +74,25 @@ export default function CandidatureStructure() { } -
- - - - + checkValidity(ref, setErrors)} + ref={ref} + > + + + +
- +
- diff --git a/src/views/candidature-structure/CandidatureStructure.test.jsx b/src/views/candidature-structure/CandidatureStructure.test.jsx index 2fb3ec2..bca7d3b 100644 --- a/src/views/candidature-structure/CandidatureStructure.test.jsx +++ b/src/views/candidature-structure/CandidatureStructure.test.jsx @@ -629,5 +629,79 @@ describe('candidature structure', () => { vi.useRealTimers(); }); + + it.each([ + { + description: 'un SIRET/RIDET', + selector: 'SIRET / RIDET * Format attendu : SIRET (12345678901234) ou RIDET (123456789)', + message: 'Veuillez renseigner le SIRET/RIDET' + }, + { + description: 'une dénomination', + selector: 'Dénomination *', + message: 'Veuillez renseigner la dénomination' + }, + { + description: 'un prénom', + selector: 'Prénom *', + message: 'Veuillez renseigner ce prénom' + }, + { + description: 'un nom', + selector: 'Nom *', + message: 'Veuillez renseigner le nom' + }, + { + description: 'une fonction', + selector: 'Fonction *', + message: 'Veuillez renseigner la fonction' + }, + { + description: 'un email', + selector: 'Adresse électronique * Format attendu : nom@domaine.fr', + message: 'Veuillez renseigner le mail' + }, + { + description: 'un téléphone', + selector: 'Téléphone * Format attendu : 0122334455 ou +33122334455', + message: 'Veuillez renseigner le téléphone' + }, + { + description: 'un nombre de conseillers souhaités', + selector: 'Combien de conseillers numériques souhaitez-vous accueillir ? *', + message: 'Veuillez renseigner le nombre de conseillers souhaités' + }, + { + description: 'une date', + selector: 'Choisir une date', + message: 'Veuillez renseigner la date' + }, + { + description: 'une motivation', + selector: 'Votre message * Limité à 2500 caractères', + message: 'Veuillez renseigner la motivation' + }, + ])('quand je valide le formulaire avec $description vide alors j’ai un message d’erreur ', async ({ selector, message }) => { + // GIVEN + vi.stubGlobal('turnstile', { + reset: vi.fn(), + remove: vi.fn(), + render: vi.fn() + }); + render(); + const champDeFormulaire = screen.getByLabelText(selector); + Object.defineProperty(champDeFormulaire, 'validationMessage', { + value: message, + configurable: true, + }); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + fireEvent.click(envoyer); + + // THEN + const contenuErreurValidation = await screen.findByText(message, { selector: 'p' }); + expect(contenuErreurValidation).toBeInTheDocument(); + }); }); diff --git a/src/views/candidature-structure/CompanyFinder.jsx b/src/views/candidature-structure/CompanyFinder.jsx index de29c4b..b72cb85 100644 --- a/src/views/candidature-structure/CompanyFinder.jsx +++ b/src/views/candidature-structure/CompanyFinder.jsx @@ -3,7 +3,7 @@ import Input from '../../components/commun/Input'; import { debounce } from '../candidature-conseiller/debounce'; import PropTypes from 'prop-types'; -export default function CompanyFinder({ onSearch }) { +export default function CompanyFinder({ onSearch, errors }) { const handleSearch = debounce(value => { onSearch(value); }); @@ -12,8 +12,9 @@ export default function CompanyFinder({ onSearch }) { handleSearch(event.target.value)} - pattern="^(?:[0-9]{9}|[0-9]{14})$" - maxlength="14" + pattern="^(?:[0-9]{9}|[0-9]{14})$" + maxlength="14" + error={errors.siret} > SIRET / RIDET * Format attendu : SIRET (12345678901234) ou RIDET (123456789) @@ -21,5 +22,6 @@ export default function CompanyFinder({ onSearch }) { } CompanyFinder.propTypes = { - onSearch: PropTypes.func + onSearch: PropTypes.func, + errors: PropTypes.object, }; diff --git a/src/views/candidature-structure/InformationsDeContact.jsx b/src/views/candidature-structure/InformationsDeContact.jsx index 0e4725b..f965765 100644 --- a/src/views/candidature-structure/InformationsDeContact.jsx +++ b/src/views/candidature-structure/InformationsDeContact.jsx @@ -1,7 +1,8 @@ import React from 'react'; import Input from '../../components/commun/Input'; +import PropTypes from 'prop-types'; -export default function InformationsDeContact() { +export default function InformationsDeContact({ errors }) { return (
Vos informations de contact @@ -9,18 +10,21 @@ export default function InformationsDeContact() { Prénom * Nom * Fonction * @@ -29,6 +33,7 @@ export default function InformationsDeContact() { name="email" type="email" pattern=".+@.+\..{2,}" + error={errors.email} > Adresse électronique * Format attendu : nom@domaine.fr @@ -37,6 +42,7 @@ export default function InformationsDeContact() { name="telephone" type="tel" pattern="([+][0-9]{11,12})|([0-9]{10})" + error={errors.telephone} > Téléphone *{' '} Format attendu : 0122334455 ou +33122334455 @@ -44,3 +50,7 @@ export default function InformationsDeContact() {
); } + +InformationsDeContact.propTypes = { + errors: PropTypes.object, +}; diff --git a/src/views/candidature-structure/InformationsDeStructure.jsx b/src/views/candidature-structure/InformationsDeStructure.jsx index 9fef55c..1de88a8 100644 --- a/src/views/candidature-structure/InformationsDeStructure.jsx +++ b/src/views/candidature-structure/InformationsDeStructure.jsx @@ -10,7 +10,7 @@ const TAILLE_SIRET = 14; const TAILLE_RIDET = [6, 7]; const TAILLES_POSSIBLES = [...TAILLE_RIDET, TAILLE_SIRET]; -export default function InformationsDeStructure({ setGeoLocation, setCodeCommune }) { +export default function InformationsDeStructure({ setGeoLocation, setCodeCommune, errors }) { const { entreprise, search, @@ -55,13 +55,14 @@ export default function InformationsDeStructure({ setGeoLocation, setCodeCommune > Vos informations de structure
- + setDenomination(event.target.value)} + error={errors.denomination} > Dénomination * @@ -70,10 +71,11 @@ export default function InformationsDeStructure({ setGeoLocation, setCodeCommune id="adresse" value={adresse} onChange={handleAdresseChange} - disabled ={!entreprise?.isRidet} + disabled={!entreprise?.isRidet} list="adresseSuggestions" isLoading={(entreprise && loading && !entreprise?.isRidet) || addressLoading} ariaBusy={(entreprise && loading && !entreprise?.isRidet) || addressLoading} + error={errors.adresse} > Adresse * @@ -121,4 +123,5 @@ InformationsDeStructure.propTypes = { setGeoLocation: PropTypes.func.isRequired, setCodeCommune: PropTypes.func.isRequired, geoLocation: PropTypes.object, + errors: PropTypes.object, }; diff --git a/src/views/candidature-structure/Motivation.jsx b/src/views/candidature-structure/Motivation.jsx index 0004c64..a0f674b 100644 --- a/src/views/candidature-structure/Motivation.jsx +++ b/src/views/candidature-structure/Motivation.jsx @@ -1,7 +1,8 @@ import React from 'react'; import ZoneDeTexte from '../../components/commun/ZoneDeTexte'; +import PropTypes from 'prop-types'; -export default function Motivation() { +export default function Motivation({ errors }) { return (
Votre motivation @@ -9,9 +10,13 @@ export default function Motivation() { En quelques lignes, décrivez le motif de votre besoin en recrutement.{' '} Indiquez les actions prévues, la justification du poste, ainsi que le public ciblé.

- + Votre message * Limité à 2500 caractères
); } + +Motivation.propTypes = { + errors: PropTypes.object +};