From 84923d2f46171af14a35bf119a391c64ea2a5c02 Mon Sep 17 00:00:00 2001 From: Ornella <68587983+Ornella452@users.noreply.github.com> Date: Thu, 10 Oct 2024 17:04:31 +0200 Subject: [PATCH] MEP (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Optimisation du build * build(deps): bump the minor-and-patch group with 2 updates Bumps the minor-and-patch group with 2 updates: [@reduxjs/toolkit](https://github.com/reduxjs/redux-toolkit) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `@reduxjs/toolkit` from 2.2.6 to 2.2.7 - [Release notes](https://github.com/reduxjs/redux-toolkit/releases) - [Commits](https://github.com/reduxjs/redux-toolkit/compare/v2.2.6...v2.2.7) Updates `vite` from 5.3.4 to 5.3.5 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.3.5/packages/vite) --- updated-dependencies: - dependency-name: "@reduxjs/toolkit" dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: vite dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] * Creation formulaire candidature structure (#196) * :feat intialisation des tests formulaire candidature structure * test formulaire partie 'information contact' + 'votre besoin CN' * Retours de code review * Ajout d'un scroll vers la bonne section du formulaire * :feat ajout premier bloc formulaire informations contact structure * Refacto des informations de structure * partie Votre motivation + test * :feat ajout premier bloc formulaire informations contact structure * Refacto des informations de structure * Retours de code review * partie engagement + test * partie submit + test + rectif lint * :feat intialisation des tests formulaire candidature structure * test formulaire partie 'information contact' + 'votre besoin CN' * Retours de code review * Ajout d'un scroll vers la bonne section du formulaire * partie Votre motivation + test * :feat ajout premier bloc formulaire informations contact structure * Refacto des informations de structure * partie engagement + test * partie submit + test + rectif lint * :feat ajout premier bloc formulaire informations contact structure * Refacto des informations de structure * Retours de code review * Correction des tests * quelques rectif required + config min + ponctuation * retour commentaire , required pour la checkbox * :feat encart information structure * :fix refacto nommage retours commentaires --------- Co-authored-by: dienamo Co-authored-by: Ornella Ourfi * Feat: Creation formulaire candidature structure coordo (#198) * Refacto formulaire conseiller * :feat intialisation des tests formulaire candidature structure * test formulaire partie 'information contact' + 'votre besoin CN' * Retours de code review * Ajout d'un scroll vers la bonne section du formulaire * partie Votre motivation + test * :feat ajout premier bloc formulaire informations contact structure * Refacto des informations de structure * partie engagement + test * :feat ajout premier bloc formulaire informations contact structure * Refacto des informations de structure * Correction des tests * Ajout du formulaire coordinateur --------- Co-authored-by: dienamo Co-authored-by: Ornella Ourfi * Modifications formulaire conseiller (#206) * Refacto du formulaire conseiller * Fix merge conflicts * Fix: input date avec la règle minimum date du jour (#201) * On ne peut pas valider le formulaire de candidature d'un conseiller si on n'a pas rempli au moins une situation Le test n'était pas correct car il testait que la phrase s'affiche mais le formulaire se validait quand même. * Simplification du formulaire de candidature conseiller en réduisant le nombre de rendu quand on coche une situation * rectif formulaire CN pour règle métier minimum date du jour * resolve test failed * Fix tests --------- Co-authored-by: Fabien Mercier Co-authored-by: Alezco * Appel du back-end depuis le formulaire (#207) * Appel du back-end depuis le formulaire * Ajout de la page de formulaire validé * Correction des problèmes de validation * Ajout de tests pour la candidature validée * Retours de code review * Retours supplémentaires de code review * WIP : test d'erreur à faire fonctionner * Fix test stub * Ajout d'un test de changement de page * Amélioration des tests (#211) * Amélioration de npm install * Ajout d'une vérification de captcha avec hCaptcha (#213) * Ajout du captcha sur le formulaire coordo (#216) * :feat recherche entreprise par siret ou ridet (#209) * :feat rechereche entreprise par siret ou ridet * :feat ajout du loader dans l'input * :feat tests de recherche par siret ou ridet * :feat refactorisation pour avoir les valeurs de formData directement * :feat refactorisation du hook de gestion des données insee ou adresse * :feat mise à jour des composants pour récuperation dans formdata * :fix retours commentaires et migration scss-->css * Feat: confirmation email (#215) * confirmation email * lint * Connexion au back-end du formulaire structure (#218) * Refacto * Ajout des informations de la ville * Feat: Conformation email structure (#219) * bouton confirmation pour l'inscription structure * renommage component pour dissocier avec celle version structure * fix nommage * retour commentaire * fix lint * fix : regex + nommage etc.. (#221) * Ajout du lien au back-end sur le formulaire coordinateur (#220) * Ajout du lien au back-end sur le formulaire coordinateur * Ajout de tests * Correction de l'appel à la GeoAPI --------- Co-authored-by: Ornella <68587983+Ornella452@users.noreply.github.com> * Fix: url api (#222) * fix url api * fix return function pour renvoyer les données codeCommune, codeRegon etc.. * Fix + refactor formulaire (#225) * 1er refactor (codeCommune en paramètre + rectif de récuperation du cdde postal au lieux de code commune * import alert dsfr * renommage composant candidature-validee => candidature-validee-conseiller + rectif wording * copy candidature validee conseiller en version pour structure * resolve warning Error: Not implemented: window.scrollTo * test pour récupérer la valeur après le build (version avant refacto api geo) * ajout de 2 tests pour le téléphone dans le cas où c'est rempli par l'utilisateur * refacto codeCommune + api adresse * correction test * fix correction required champs * refactor window scrollTo * retour commentaire * refactor code commune + ajout du '*' pur l'input required à true * fix get info commune * fix: ajout trim + autocompletion + gestion erreur (#227) * fix trim * aucompletion off pour le form conseiller * gestion dans le cas failed to fetch * retour commentaire * recif wording + png (#228) * recif wording message erreur * remplace img png par avif * fix avif en png (#229) --------- Signed-off-by: dependabot[bot] Co-authored-by: Fabien Mercier Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Benjamin Morali Co-authored-by: dienamo Co-authored-by: dienamo <46058257+dienamo@users.noreply.github.com> --- .env | 1 + .github/workflows/continuous-integration.yml | 4 +- .github/workflows/deploy-production.yml | 1 + .github/workflows/deploy-recette.yml | 1 + index.html | 1 + package-lock.json | 30 +- package.json | 6 +- public/assets/img/email/header_default.png | Bin 13210 -> 17647 bytes src/App.js | 91 ++- src/assets/js/index.js | 1 - src/assets/sass/colors/_colors.scss | 9 - src/assets/sass/components/_header.scss | 10 +- src/assets/sass/main.scss | 6 - src/assets/sass/views/_aide.scss | 7 +- src/assets/sass/views/_carte.scss | 7 +- .../sass/views/_coordinationTerritoriale.scss | 21 +- src/assets/sass/views/_formation.scss | 10 +- src/assets/sass/views/_kitCommunication.scss | 4 +- src/assets/sass/views/_mentionsLegales.scss | 2 - .../sass/views/accueil/_accompagnements.scss | 3 +- src/assets/sass/views/accueil/_aide.scss | 2 - src/assets/sass/views/accueil/_bienvenue.scss | 13 +- src/assets/sass/views/accueil/_priseRDV.scss | 11 +- .../sass/views/accueil/_rencontres.scss | 9 +- .../sass/views/accueil/_statistiques.scss | 14 +- src/assets/sass/views/accueil/_themes.scss | 13 +- src/components/Footer.js | 3 +- src/components/Header.js | 10 + src/components/Menu.js | 19 +- .../Sommaire.jsx | 32 +- src/components/commun/Alert.jsx | 17 + src/components/commun/Badge.jsx | 2 +- src/components/commun/BoutonRadio.jsx | 3 +- src/components/commun/Captcha.jsx | 19 + src/components/commun/Checkbox.jsx | 6 +- src/components/commun/Datepicker.jsx | 8 +- src/components/commun/Input.jsx | 34 +- src/hooks/useScrollToSection.js | 13 + src/views/Carte.js | 7 +- .../candidature-conseiller/AddressChooser.jsx | 24 +- .../CandidatureConseiller.css | 14 + .../CandidatureConseiller.jsx | 92 ++- .../CandidatureConseiller.test.jsx | 529 +++++++++++++-- .../candidature-conseiller/Disponibilite.jsx | 20 +- src/views/candidature-conseiller/EnResume.jsx | 10 +- .../InformationsDeContact.jsx | 6 +- .../candidature-conseiller/Motivation.jsx | 4 +- .../SituationEtExperience.jsx | 26 +- .../SommaireConseiller.jsx | 27 + .../candidature-conseiller/situations.js | 8 +- .../candidature-conseiller/useApiAdmin.js | 146 +++++ src/views/candidature-conseiller/useGeoApi.js | 32 +- .../BesoinEnCoordinateur.jsx | 36 + .../CandidatureCoordinateur.jsx | 87 +++ .../CandidatureCoordinateur.test.jsx | 522 +++++++++++++++ .../CompanyFinder.jsx | 20 + .../candidature-coordinateur/Engagement.jsx | 27 + .../candidature-coordinateur/Motivation.jsx | 31 + .../PageCandidatureCoordinateur.jsx | 12 + .../SommaireCoordinateur.jsx | 27 + .../BesoinEnConseillerNumerique.jsx | 33 + .../CandidatureStructure.css | 76 +++ .../CandidatureStructure.jsx | 88 +++ .../CandidatureStructure.test.jsx | 615 ++++++++++++++++++ .../candidature-structure/CompanyFinder.jsx | 24 + .../candidature-structure/Engagement.jsx | 23 + .../InformationsDeContact.jsx | 44 ++ .../InformationsDeStructure.jsx | 124 ++++ .../candidature-structure/Motivation.jsx | 17 + .../PageCandidatureStructure.jsx | 12 + .../SommaireStructure.jsx | 27 + .../useEntrepriseFinder.js | 119 ++++ .../CandidatureValideeConseiller.css | 7 + .../CandidatureValideeConseiller.jsx | 24 + .../CandidatureValideeConseiller.test.jsx | 28 + .../PageCandidatureValideeConseiller.jsx | 12 + .../CandidatureValideeStructure.css | 7 + .../CandidatureValideeStructure.jsx | 25 + .../CandidatureValideeStructure.test.jsx | 29 + .../PageCandidatureValideeStructure.jsx | 12 + ...ConfirmationEmailCandidatureConseiller.jsx | 42 ++ ...ConfirmationEmailCandidatureConseiller.jsx | 12 + ...rmationEmailCandidatureConseiller.test.jsx | 125 ++++ ...iConfirmationEmailCandidatureConseiller.js | 19 + .../ConfirmationEmailCandidatureStructure.jsx | 42 ++ ...eConfirmationEmailCandidatureStructure.jsx | 12 + ...irmationEmailCandidatureStructure.test.jsx | 125 ++++ ...piConfirmationEmailCandidatureStructure.js | 17 + .../CarteCoordinateur.js | 8 +- src/views/formation/FormationInitiale.js | 13 +- .../test-utils.js | 2 + vitest.setup.js | 5 + 92 files changed, 3569 insertions(+), 349 deletions(-) delete mode 100644 src/assets/js/index.js rename src/{views/candidature-conseiller => components}/Sommaire.jsx (53%) create mode 100644 src/components/commun/Alert.jsx create mode 100644 src/components/commun/Captcha.jsx create mode 100644 src/hooks/useScrollToSection.js create mode 100644 src/views/candidature-conseiller/SommaireConseiller.jsx create mode 100644 src/views/candidature-conseiller/useApiAdmin.js create mode 100644 src/views/candidature-coordinateur/BesoinEnCoordinateur.jsx create mode 100644 src/views/candidature-coordinateur/CandidatureCoordinateur.jsx create mode 100644 src/views/candidature-coordinateur/CandidatureCoordinateur.test.jsx create mode 100644 src/views/candidature-coordinateur/CompanyFinder.jsx create mode 100644 src/views/candidature-coordinateur/Engagement.jsx create mode 100644 src/views/candidature-coordinateur/Motivation.jsx create mode 100644 src/views/candidature-coordinateur/PageCandidatureCoordinateur.jsx create mode 100644 src/views/candidature-coordinateur/SommaireCoordinateur.jsx create mode 100644 src/views/candidature-structure/BesoinEnConseillerNumerique.jsx create mode 100644 src/views/candidature-structure/CandidatureStructure.css create mode 100644 src/views/candidature-structure/CandidatureStructure.jsx create mode 100644 src/views/candidature-structure/CandidatureStructure.test.jsx create mode 100644 src/views/candidature-structure/CompanyFinder.jsx create mode 100644 src/views/candidature-structure/Engagement.jsx create mode 100644 src/views/candidature-structure/InformationsDeContact.jsx create mode 100644 src/views/candidature-structure/InformationsDeStructure.jsx create mode 100644 src/views/candidature-structure/Motivation.jsx create mode 100644 src/views/candidature-structure/PageCandidatureStructure.jsx create mode 100644 src/views/candidature-structure/SommaireStructure.jsx create mode 100644 src/views/candidature-structure/useEntrepriseFinder.js create mode 100644 src/views/candidature-validee-conseiller/CandidatureValideeConseiller.css create mode 100644 src/views/candidature-validee-conseiller/CandidatureValideeConseiller.jsx create mode 100644 src/views/candidature-validee-conseiller/CandidatureValideeConseiller.test.jsx create mode 100644 src/views/candidature-validee-conseiller/PageCandidatureValideeConseiller.jsx create mode 100644 src/views/candidature-validee-structure/CandidatureValideeStructure.css create mode 100644 src/views/candidature-validee-structure/CandidatureValideeStructure.jsx create mode 100644 src/views/candidature-validee-structure/CandidatureValideeStructure.test.jsx create mode 100644 src/views/candidature-validee-structure/PageCandidatureValideeStructure.jsx create mode 100644 src/views/confirmation-email-candidature-conseiller/ConfirmationEmailCandidatureConseiller.jsx create mode 100644 src/views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller.jsx create mode 100644 src/views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller.test.jsx create mode 100644 src/views/confirmation-email-candidature-conseiller/useApiConfirmationEmailCandidatureConseiller.js create mode 100644 src/views/confirmation-email-candidature-structure/ConfirmationEmailCandidatureStructure.jsx create mode 100644 src/views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure.jsx create mode 100644 src/views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure.test.jsx create mode 100644 src/views/confirmation-email-candidature-structure/useApiConfirmationEmailCandidatureStructure.js rename {src/views/candidature-conseiller => test}/test-utils.js (58%) diff --git a/.env b/.env index e60ebd9d..829323ee 100644 --- a/.env +++ b/.env @@ -5,3 +5,4 @@ VITE_APP_CANDIDAT_URL=https://candidat.conseiller-numerique.gouv.fr/login VITE_APP_FORMS_URL=https://app.conseiller-numerique.gouv.fr/candidature VITE_APP_AIDE_URL=https://aide.conseiller-numerique.gouv.fr/fr VITE_APP_API_URL=http://localhost:8080 +VITE_APP_API_PILOTAGE_URL=http://localhost:8080 diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 1f6e8cf6..23f07344 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -12,7 +12,7 @@ jobs: cache: npm node-version-file: package.json - name: Install modules - run: npm i + run: npm i --no-audit --prefer-offline - name: Lint run: npm run lint @@ -25,6 +25,6 @@ jobs: cache: npm node-version-file: package.json - name: Install modules - run: npm i + run: npm i --no-audit --prefer-offline - name: Test run: npm run test diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index e8cf3087..7da8a230 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -39,6 +39,7 @@ jobs: envkey_VITE_APP_FORMS_URL: https://app.conseiller-numerique.gouv.fr/candidature envkey_VITE_APP_AIDE_URL: https://aide.conseiller-numerique.gouv.fr/fr envkey_VITE_APP_API_URL: ${{ secrets.REACT_APP_API_PRODUCTION }} + envkey_VITE_APP_API_PILOTAGE_URL: ${{ secrets.REACT_APP_API_PILOTAGE_PRODUCTION }} file_name: .env - name: Build application run: npm run build diff --git a/.github/workflows/deploy-recette.yml b/.github/workflows/deploy-recette.yml index 98c8b27f..63a1b7f1 100644 --- a/.github/workflows/deploy-recette.yml +++ b/.github/workflows/deploy-recette.yml @@ -39,6 +39,7 @@ jobs: envkey_VITE_APP_FORMS_URL: https://uat-cnum-front.osc-fr1.scalingo.io/candidature envkey_VITE_APP_AIDE_URL: https://aide.conseiller-numerique.gouv.fr/fr envkey_VITE_APP_API_URL: ${{ secrets.REACT_APP_API_RECETTE }} + envkey_VITE_APP_API_PILOTAGE_URL: ${{ secrets.REACT_APP_API_PILOTAGE_RECETTE }} file_name: .env - name: Copy robots.txt uses: canastro/copy-file-action@master diff --git a/index.html b/index.html index 5cf0e701..fbbab0c0 100644 --- a/index.html +++ b/index.html @@ -43,5 +43,6 @@ })(); + diff --git a/package-lock.json b/package-lock.json index 6d4d5cf0..bec69fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,15 @@ "dependencies": { "@gouvfr-anct/cartographie-nationale": "^5.22.0", "@gouvfr/dsfr": "1.12.1", - "@reduxjs/toolkit": "^2.2.6", + "@reduxjs/toolkit": "^2.2.7", "dayjs": "^1.11.12", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.1.2", "react-router-dom": "^6.25.1", - "react-router-hash-link": "^2.4.3", "react-tooltip": "^5.27.1", "redux": "^5.0.1", - "vite": "^5.3.4", + "vite": "^5.3.5", "web-vitals": "^4.2.2" }, "devDependencies": { @@ -1155,9 +1154,9 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.6.tgz", - "integrity": "sha512-kH0r495c5z1t0g796eDQAkYbEQ3a1OLYN9o8jQQVZyKyw367pfRGS+qZLkHYvFHiUUdafpoSlQ2QYObIApjPWA==", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.7.tgz", + "integrity": "sha512-faI3cZbSdFb8yv9dhDTmGwclW0vk0z5o1cia+kf7gCbaCwHI5e+7tP57mJUv22pNcNbeA62GSrPpfrUfdXcQ6g==", "dependencies": { "immer": "^10.0.3", "redux": "^5.0.1", @@ -5752,6 +5751,7 @@ }, "node_modules/prop-types": { "version": "15.8.1", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5761,6 +5761,7 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", + "dev": true, "license": "MIT" }, "node_modules/psl": { @@ -5929,17 +5930,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-router-hash-link": { - "version": "2.4.3", - "license": "MIT", - "dependencies": { - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "react": ">=15", - "react-router-dom": ">=4" - } - }, "node_modules/react-tooltip": { "version": "5.27.1", "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.27.1.tgz", @@ -7131,9 +7121,9 @@ } }, "node_modules/vite": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz", - "integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", + "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.39", diff --git a/package.json b/package.json index 164a0ad8..0dc73bd2 100644 --- a/package.json +++ b/package.json @@ -9,21 +9,21 @@ "lint": "eslint src --max-warnings=0 --cache --cache-location node_modules/.cache/eslint", "start": "vite preview", "test": "vitest --run", + "test:watch": "vitest --watch", "test:coverage": "npm test -- --coverage" }, "dependencies": { "@gouvfr-anct/cartographie-nationale": "^5.22.0", "@gouvfr/dsfr": "1.12.1", - "@reduxjs/toolkit": "^2.2.6", + "@reduxjs/toolkit": "^2.2.7", "dayjs": "^1.11.12", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.1.2", "react-router-dom": "^6.25.1", - "react-router-hash-link": "^2.4.3", "react-tooltip": "^5.27.1", "redux": "^5.0.1", - "vite": "^5.3.4", + "vite": "^5.3.5", "web-vitals": "^4.2.2" }, "devDependencies": { diff --git a/public/assets/img/email/header_default.png b/public/assets/img/email/header_default.png index 331dfd88eed15e16af83ae7d9d3d7f3e94c36002..1d8f528ae1164e219084bbc2889818dcb116d590 100644 GIT binary patch literal 17647 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 SA)wG7q{!3N&t;ucLK6UcvOI18 literal 13210 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 edekF#C&=DWwNA81= { const { permanence } = useParams(); return ; }; - const GestionHash = () => { const location = useLocation(); const allowedAncres = ['#ancre-themes', '#ancre-statistiques']; @@ -33,35 +33,56 @@ const GestionHash = () => { }; function App() { + const Carte = lazy(() => import('./views/Carte')); + const CoordinationTerritoriale = lazy(() => import('./views/coordination-territoriale')); + const CarteCoordinateur = lazy(() => import('./views/coordination-territoriale/CarteCoordinateur')); + const PageCandidatureConseiller = lazy(() => import('./views/candidature-conseiller/PageCandidatureConseiller')); + const PageCandidatureStructure = lazy(() => import('./views/candidature-structure/PageCandidatureStructure')); + const PageCandidatureCoordinateur = lazy(() => import('./views/candidature-coordinateur/PageCandidatureCoordinateur')); + const PageCandidatureValideeConseiller = lazy(() => import('./views/candidature-validee-conseiller/PageCandidatureValideeConseiller')); + const PageCandidatureValideeStructure = lazy(() => import('./views/candidature-validee-structure/PageCandidatureValideeStructure')); + const PageConfirmationEmailCandidatureConseiller = + lazy(() => import('./views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller')); + const PageConfirmationEmailCandidatureStructure = + lazy(() => import('./views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure')); + return (
- - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - -
- + + + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + +
+ +
); } diff --git a/src/assets/js/index.js b/src/assets/js/index.js deleted file mode 100644 index 20155e78..00000000 --- a/src/assets/js/index.js +++ /dev/null @@ -1 +0,0 @@ -import '@gouvfr/dsfr/dist/dsfr/dsfr.module'; diff --git a/src/assets/sass/colors/_colors.scss b/src/assets/sass/colors/_colors.scss index fd042f68..67464d81 100644 --- a/src/assets/sass/colors/_colors.scss +++ b/src/assets/sass/colors/_colors.scss @@ -1,21 +1,12 @@ -$blue-france: #000091; $blue-marine:#243B7C; $blue-medium: #2f29ff; $blue-secondary: #0067E2; -$blue-light: #0063CB; -$blue-lighter: #E8EDFF; $blue-active: #6262fb; $yellow-light: #FFCA00; $yellow-dark: #987721; $green-transparent: #62D2C0; $green-pale: #09B8A6; $green-light: #1DD884; -$green-dark: #297254; $red-dark: #FF2D2D; -$white: #FFFFFF; $grey: #383838; -$grey-light: #3A3A3A; -$grey-background: #EEEEEE; -$red-medium: #CE0500; -$light-grey: #666666; $lighter-grey: #E6E6E6; diff --git a/src/assets/sass/components/_header.scss b/src/assets/sass/components/_header.scss index 1f591d2a..a823d059 100644 --- a/src/assets/sass/components/_header.scss +++ b/src/assets/sass/components/_header.scss @@ -1,22 +1,22 @@ -@import '../colors/colors'; - .fr-header { .link-renouvellement-conventions { - color: $red-medium !important; + color: var(--error-425-625); } + .fr-header__tools-links .fr-links-group > li { padding-left: 5px; } + .fr-header__menu-links .fr-links-group > li { display: inline; line-height: 2.75rem; padding-bottom: 1rem; } + .fr-header__menu-links .fr-links-group > li a { - font-size: 15px !important; + font-size: 15px; } - // Supprimer le picto flèche [target=_blank]::after { display: none; } diff --git a/src/assets/sass/main.scss b/src/assets/sass/main.scss index 42f2e7a5..28467d08 100644 --- a/src/assets/sass/main.scss +++ b/src/assets/sass/main.scss @@ -7,9 +7,3 @@ @use './views/carte'; @use './components/footer'; @use './components/header'; -@use '../../../public/styles-5.22.0'; - -@import '../../../node_modules/@gouvfr/dsfr/dist/dsfr/dsfr.min.css'; -@import '../../../node_modules/@gouvfr/dsfr/dist/utility/icons/icons-communication/icons-communication.min.css'; -@import '../../../node_modules/@gouvfr/dsfr/dist/utility/icons/icons-document/icons-document.min.css'; -@import '../../../node_modules/@gouvfr/dsfr/dist/utility/icons/icons-system/icons-system.min.css'; diff --git a/src/assets/sass/views/_aide.scss b/src/assets/sass/views/_aide.scss index 40ac4a22..3bc15c6a 100644 --- a/src/assets/sass/views/_aide.scss +++ b/src/assets/sass/views/_aide.scss @@ -1,7 +1,6 @@ @import '../colors/colors'; .aideCandidat, .aideStructure { - a[target=_blank]::after { display: none; /* suppression du picto target blank */ } @@ -23,7 +22,7 @@ } .white-text { - color: $white; + color: var(--grey-1000-50); } .buttonCustom { @@ -32,7 +31,7 @@ min-height: 2.5rem; line-height: 1.25rem; text-align: center; - background: $white; + background: var(--grey-1000-50); display: inline-block; padding: 1.5rem; border: none; @@ -88,7 +87,5 @@ .partProgram { flex-direction: column; } - } - } diff --git a/src/assets/sass/views/_carte.scss b/src/assets/sass/views/_carte.scss index 92d06159..2cdf7c8e 100644 --- a/src/assets/sass/views/_carte.scss +++ b/src/assets/sass/views/_carte.scss @@ -1,9 +1,8 @@ .carte { - margin-Bottom: 175px; h1 { - color: white; + color: var(--grey-1000-50); } .fr-nav__list > * > .fr-nav__link[aria-current] { @@ -22,7 +21,6 @@ background: transparent url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23000%27%3e%3cpath d=%27M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z%27/%3e%3c/svg%3e") center/1em auto no-repeat; } - // Cacher le bouton Dora .update-with-dora { display: none; } @@ -30,15 +28,14 @@ nav.z-index-over-base { z-index: 10 !important; } + app-informations-generales { z-index: 1 !important; } } @media print { - .carte > header, .carte + footer { display: none !important; } - } diff --git a/src/assets/sass/views/_coordinationTerritoriale.scss b/src/assets/sass/views/_coordinationTerritoriale.scss index a614f39d..4290512a 100644 --- a/src/assets/sass/views/_coordinationTerritoriale.scss +++ b/src/assets/sass/views/_coordinationTerritoriale.scss @@ -10,7 +10,7 @@ } h2#hubModal a { - color: $blue-france; + color: var(--blue-france-sun-113-625); } .list-group-flush > .list-group-item { @@ -55,11 +55,11 @@ } .bleu-primary { - color: $blue-france; + color: var(--blue-france-sun-113-625); } .gris-light { - color: $light-grey; + color: var(--grey-425-625); } .primary-list { @@ -68,7 +68,7 @@ } .gris-background { - background-color: $grey-background; + background-color: var(--grey-950-125); transition: all 0.2s ease-in; }; @@ -89,9 +89,11 @@ .secondary-list { list-style: disc; } + .secondary-list li::marker { - color: $blue-france; + color: var(--blue-france-sun-113-625); } + .title-mission { text-align: center; margin: 0; @@ -126,16 +128,16 @@ .section-informations { margin-top: 6.5rem; - background-color: $blue-france; + background-color: var(--blue-france-sun-113-625); } .block-text-informations-1 { - color: white; + color: var(--grey-1000-50); width: 60%; } .block-text-informations-2 { - color: white; + color: var(--grey-1000-50); width: 62%; } @@ -258,14 +260,11 @@ .image-cartographie-coordinateur { width: 50%; } - } @media (max-width: 800px) { - .marge-image { margin-bottom: 5rem; } } - } diff --git a/src/assets/sass/views/_formation.scss b/src/assets/sass/views/_formation.scss index 42ff19dd..e180cc70 100644 --- a/src/assets/sass/views/_formation.scss +++ b/src/assets/sass/views/_formation.scss @@ -5,7 +5,7 @@ font-size: 1rem !important; line-height: 1.5rem !important; a { - color: #000091; + color: var(--blue-france-sun-113-625); } .fresque { @@ -18,13 +18,13 @@ .title-h3 { font-size: 1.125rem; - color: $blue-light; + color: var(--info-425-625); margin-bottom: 0; margin-top: 0; } .description { - color: $light-grey; + color: var(--grey-425-625); font-size: 0.75rem; font-weight: 400; line-height: 20px; @@ -37,12 +37,12 @@ } .encart-bleu { - background: $blue-lighter; + background: var(--info-950-100); color: $blue-secondary; } .cadre-bleu { - background: $blue-lighter; + background: var(--info-950-100); text-align: center; margin: 0 auto; padding: 2% 0; diff --git a/src/assets/sass/views/_kitCommunication.scss b/src/assets/sass/views/_kitCommunication.scss index e3b729c8..aca73f63 100644 --- a/src/assets/sass/views/_kitCommunication.scss +++ b/src/assets/sass/views/_kitCommunication.scss @@ -1,5 +1,3 @@ -@import '../colors/colors'; - .kit-communication { a[target=_blank]::after { display: none; /* suppression du picto target blank */ @@ -27,7 +25,7 @@ } .bleu-france { - color: $blue-france; + color: var(--blue-france-sun-113-625); } .summary-custom { diff --git a/src/assets/sass/views/_mentionsLegales.scss b/src/assets/sass/views/_mentionsLegales.scss index ca7f9ba8..fa7f303c 100644 --- a/src/assets/sass/views/_mentionsLegales.scss +++ b/src/assets/sass/views/_mentionsLegales.scss @@ -1,9 +1,7 @@ @import '../colors/colors'; .mentionsLegales { - .bleu-secondaire { color: $blue-secondary; } - } diff --git a/src/assets/sass/views/accueil/_accompagnements.scss b/src/assets/sass/views/accueil/_accompagnements.scss index 6f6bb532..255b8b52 100644 --- a/src/assets/sass/views/accueil/_accompagnements.scss +++ b/src/assets/sass/views/accueil/_accompagnements.scss @@ -1,7 +1,6 @@ @import '../../colors/colors'; .accompagnements { - .typePart { display: flex; flex-grow: 1; @@ -22,7 +21,7 @@ } .greenPart { - background: linear-gradient(to bottom, $green-light, $green-dark 100% ); + background: linear-gradient(to bottom, $green-light, var(--green-emeraude-sun-425-moon-753) 100% ); justify-content: flex-start; } diff --git a/src/assets/sass/views/accueil/_aide.scss b/src/assets/sass/views/accueil/_aide.scss index 80ae5e9f..1cb7302c 100644 --- a/src/assets/sass/views/accueil/_aide.scss +++ b/src/assets/sass/views/accueil/_aide.scss @@ -47,7 +47,6 @@ } @media (max-width: 576px) { - .rowCustom { flex-direction: column; } @@ -61,6 +60,5 @@ padding: 0; padding-top: 1rem; } - } } diff --git a/src/assets/sass/views/accueil/_bienvenue.scss b/src/assets/sass/views/accueil/_bienvenue.scss index e0ca6def..736843d8 100644 --- a/src/assets/sass/views/accueil/_bienvenue.scss +++ b/src/assets/sass/views/accueil/_bienvenue.scss @@ -1,11 +1,8 @@ -@import '../../colors/colors'; - .bienvenue { min-height: 100vh; display: flex; flex-direction: column; - background: linear-gradient(to bottom right, $blue-light 30%, $blue-france 100%); - + background: linear-gradient(to bottom right, var(--info-425-625) 30%, var(--blue-france-sun-113-625) 100%); .presentationPart { display: flex; @@ -31,7 +28,7 @@ } .title { - color: $white; + color: var(--grey-1000-50); margin-bottom: 28px; } @@ -49,8 +46,8 @@ } .btnCustom { - background-color: $white; - color: $blue-france; + background-color: var(--grey-1000-50); + color: var(--blue-france-sun-113-625); font-family: 'Marianne Regular'; border-radius: 40px; font-size: 20px; @@ -129,6 +126,6 @@ @media (max-width: 576px) { .bienvenue { - background: linear-gradient(to bottom right, $blue-light 0%, $blue-france 100%); + background: linear-gradient(to bottom right, var(--info-425-625) 0%, var(--blue-france-sun-113-625) 100%); } } diff --git a/src/assets/sass/views/accueil/_priseRDV.scss b/src/assets/sass/views/accueil/_priseRDV.scss index 2b3fe72b..bc3ae0db 100644 --- a/src/assets/sass/views/accueil/_priseRDV.scss +++ b/src/assets/sass/views/accueil/_priseRDV.scss @@ -1,8 +1,6 @@ -@import '../../colors/colors'; - .priseRDV { height: 330px; - background: linear-gradient(to bottom, $blue-france, $blue-light 100%); + background: linear-gradient(to bottom, var(--blue-france-sun-113-625), var(--info-425-625) 100%); display: flex; align-items: flex-end; @@ -23,12 +21,12 @@ .titleRDV { padding-top: 16px; text-align: center; - color: $white; + color: var(--grey-1000-50); } .btnCustom { - background-color: $white!important; - color: $blue-france; + background-color: var(--grey-1000-50) !important; + color: var(--blue-france-sun-113-625); border-radius: 40px; font-size: 20px; font-family: 'Marianne Regular'; @@ -64,7 +62,6 @@ height: 80px; text-align: center; } - } @media (max-width: 768px) { diff --git a/src/assets/sass/views/accueil/_rencontres.scss b/src/assets/sass/views/accueil/_rencontres.scss index 7e175804..d48f34b0 100644 --- a/src/assets/sass/views/accueil/_rencontres.scss +++ b/src/assets/sass/views/accueil/_rencontres.scss @@ -1,11 +1,7 @@ -@import '../../colors/colors'; - -$-picto: 176px; - .rencontres { .picto { - width: $-picto; - height: $-picto; + width: 176px; + height: 176px; } .titlePart { @@ -52,7 +48,6 @@ $-picto: 176px; } @media (max-width: 576px) { - .titleSquare { line-height: 25px; font-size: 16px; diff --git a/src/assets/sass/views/accueil/_statistiques.scss b/src/assets/sass/views/accueil/_statistiques.scss index 5cae47af..8f6fa3e7 100644 --- a/src/assets/sass/views/accueil/_statistiques.scss +++ b/src/assets/sass/views/accueil/_statistiques.scss @@ -1,20 +1,20 @@ -@import '../../colors/colors'; - .statistiques { .center { - text-align: center; - } + text-align: center; + } + .baseline-maj { height: 20px; width: 209px; - color: $grey-light; + color: var(--grey-200-850); font-size: 12px; font-weight: 300; - line-height: 20px; + line-height: 20px; letter-spacing: 0; } + .texte { - color: $grey-light; + color: var(--grey-200-850); font-size: 16px; line-height: 24px; letter-spacing: 0; diff --git a/src/assets/sass/views/accueil/_themes.scss b/src/assets/sass/views/accueil/_themes.scss index 0c0be544..17e036cb 100644 --- a/src/assets/sass/views/accueil/_themes.scss +++ b/src/assets/sass/views/accueil/_themes.scss @@ -1,16 +1,7 @@ -@import '../../colors/colors'; -@font-face { - font-family: "Marianne Regular"; - src: url('~@gouvfr/dsfr/dist/fonts/Marianne-Regular.woff2') format("woff2"), - url('~@gouvfr/dsfr/dist/fonts/Marianne-Regular.woff') format("woff"); - } - -$-picto: 207px; - .themes { .picto { - width: $-picto; - height: $-picto; + width: 207px; + height: 207px; } .spaceLineHexagon { diff --git a/src/components/Footer.js b/src/components/Footer.js index 674bf98f..f9f5c92c 100644 --- a/src/components/Footer.js +++ b/src/components/Footer.js @@ -3,8 +3,9 @@ import { Link } from 'react-router-dom'; import logoFR from '../assets/brands/logo-france-relance-gouv-ue.png'; import logoAnctSonum from '../assets/brands/logo-sonum-anct-min.svg'; -function Footer() { +import '@gouvfr/dsfr/dist/component/footer/footer.min.css'; +function Footer() { const onClickLink = () => { //Effet de scroll window.scrollTo({ top: 0 }); diff --git a/src/components/Header.js b/src/components/Header.js index 9e4fbe3c..407ec2e4 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -5,6 +5,16 @@ import { Link } from 'react-router-dom'; import logo from '../assets/brands/logo-conseiller-numerique-min.svg'; import Menu from './Menu'; +import '@gouvfr/dsfr/dist/component/button/button.min.css'; +import '@gouvfr/dsfr/dist/component/link/link.min.css'; +import '@gouvfr/dsfr/dist/component/modal/modal.min.css'; +import '@gouvfr/dsfr/dist/component/navigation/navigation.min.css'; +import '@gouvfr/dsfr/dist/component/logo/logo.min.css'; +import '@gouvfr/dsfr/dist/component/header/header.min.css'; +import '@gouvfr/dsfr/dist/utility/icons/icons-communication/icons-communication.min.css'; +import '@gouvfr/dsfr/dist/utility/icons/icons-document/icons-document.min.css'; +import '@gouvfr/dsfr/dist/utility/icons/icons-system/icons-system.min.css'; + function Header() { // eslint-disable-next-line max-len const aideUrl = `${import.meta.env.VITE_APP_AIDE_URL}/article/renouvellement-quel-est-le-montant-de-la-subvention-quelle-est-la-duree-de-la-subvention-et-du-contrat-1ci8cxv/`; diff --git a/src/components/Menu.js b/src/components/Menu.js index 141c4df5..1c541eb7 100644 --- a/src/components/Menu.js +++ b/src/components/Menu.js @@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react'; import { menuActions } from '../actions'; import { useSelector, useDispatch } from 'react-redux'; import { useLocation, Link } from 'react-router-dom'; -import { HashLink } from 'react-router-hash-link'; function Menu() { @@ -65,10 +64,10 @@ function Menu() { @@ -108,7 +107,7 @@ function Menu() { to="/aide-candidat" className="fr-nav__link" {...(location.pathname.startsWith('/aide-candidat') ? { 'aria-current': 'page' } : {})}> - • Devenir conseiller numérique + Devenir conseiller numérique
  • @@ -116,7 +115,7 @@ function Menu() { to="/aide-structure" className="fr-nav__link" {...(location.pathname.startsWith('/aide-structure') ? { 'aria-current': 'page' } : {})}> - • Recruter un conseiller numérique + Recruter un conseiller numérique
  • @@ -133,22 +132,22 @@ function Menu() { diff --git a/src/views/candidature-conseiller/Sommaire.jsx b/src/components/Sommaire.jsx similarity index 53% rename from src/views/candidature-conseiller/Sommaire.jsx rename to src/components/Sommaire.jsx index d96296a5..de59f8cf 100644 --- a/src/views/candidature-conseiller/Sommaire.jsx +++ b/src/components/Sommaire.jsx @@ -1,27 +1,11 @@ import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useLocation } from 'react-router-dom'; -export default function Sommaire() { - const ancreInformationsDeContact = '#informationsDeContact'; - const [dernierElementClique, setDernierElementClique] = useState(ancreInformationsDeContact); +export default function Sommaire({ parties }) { + const { hash } = useLocation(); - const partiesSommaire = [ - { - ancre: ancreInformationsDeContact, - libelle: 'Vos informations de contact' - }, - { - ancre: '#situationEtExperience', - libelle: 'Votre situation et expérience' - }, - { - ancre: '#votreDisponibilite', - libelle: 'Votre disponibilité' - }, - { - ancre: '#votreMotivation', - libelle: 'Votre motivation' - }, - ]; + const [dernierElementClique, setDernierElementClique] = useState(hash || parties[0].ancre); const getAriaCurrent = ancre => { return ancre === dernierElementClique ? 'page' : false; @@ -30,7 +14,7 @@ export default function Sommaire() { return ( ); } + +Sommaire.propTypes = { + parties: PropTypes.array, +}; diff --git a/src/components/commun/Alert.jsx b/src/components/commun/Alert.jsx new file mode 100644 index 00000000..017e7c18 --- /dev/null +++ b/src/components/commun/Alert.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default function Alert({ children, titre }) { + return ( +
    +

    {titre}

    +

    {children}

    +
    + ); +} + +Alert.propTypes = { + children: PropTypes.node, + titre: PropTypes.string, +}; + diff --git a/src/components/commun/Badge.jsx b/src/components/commun/Badge.jsx index 3fbb6b99..9ce20c44 100644 --- a/src/components/commun/Badge.jsx +++ b/src/components/commun/Badge.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; export default function Badge({ children }) { return ( -

    +

    {children}

    ); diff --git a/src/components/commun/BoutonRadio.jsx b/src/components/commun/BoutonRadio.jsx index e8f0894d..aaea4e06 100644 --- a/src/components/commun/BoutonRadio.jsx +++ b/src/components/commun/BoutonRadio.jsx @@ -5,7 +5,7 @@ export default function BoutonRadio({ children, id, nomGroupe }) { return (
    - + @@ -18,4 +18,5 @@ BoutonRadio.propTypes = { children: PropTypes.node, id: PropTypes.string, nomGroupe: PropTypes.string, + value: PropTypes.string, }; diff --git a/src/components/commun/Captcha.jsx b/src/components/commun/Captcha.jsx new file mode 100644 index 00000000..390d4806 --- /dev/null +++ b/src/components/commun/Captcha.jsx @@ -0,0 +1,19 @@ +import React, { useEffect, useRef } from 'react'; + +const SITE_KEY = '84e24b30-44ca-488c-9260-ec80c290c166'; + +export default function Captcha() { + const captchaRef = useRef(null); + + useEffect(() => { + if (window.hcaptcha) { + window.hcaptcha.render(captchaRef.current, { + sitekey: SITE_KEY, + }); + } + }, []); + + return ( +
    + ); +} diff --git a/src/components/commun/Checkbox.jsx b/src/components/commun/Checkbox.jsx index aeff91bb..60b40ac1 100644 --- a/src/components/commun/Checkbox.jsx +++ b/src/components/commun/Checkbox.jsx @@ -1,11 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -export default function Checkbox({ children, id, onCheck, checked }) { +export default function Checkbox({ children, id, onCheck, required = true }) { return (
    - + @@ -18,5 +18,5 @@ Checkbox.propTypes = { children: PropTypes.node, id: PropTypes.string, onCheck: PropTypes.func, - checked: PropTypes.bool, + required: PropTypes.bool }; diff --git a/src/components/commun/Datepicker.jsx b/src/components/commun/Datepicker.jsx index 6bf4e64e..f13d28a4 100644 --- a/src/components/commun/Datepicker.jsx +++ b/src/components/commun/Datepicker.jsx @@ -1,13 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -export default function Datepicker({ children, id, isRequired = true, onChange }) { +export default function Datepicker({ children, id, isRequired = true, onChange, min }) { return (
    - +
    ); } @@ -16,5 +16,7 @@ Datepicker.propTypes = { children: PropTypes.node, id: PropTypes.string, isRequired: PropTypes.bool, - onChange: PropTypes.func + onChange: PropTypes.func, + min: PropTypes.string, + name: PropTypes.string, }; diff --git a/src/components/commun/Input.jsx b/src/components/commun/Input.jsx index d72042dd..c08fadc8 100644 --- a/src/components/commun/Input.jsx +++ b/src/components/commun/Input.jsx @@ -1,12 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; -export default function Input({ children, id, isRequired = true, type = 'text', pattern, onChange, list }) { +export default function Input({ children, id, isRequired = true, autoComplete = 'on', testId = '', type = 'text', + pattern, onChange, list, min, readOnly, isLoading, ariaBusy, value }) { return (
    - + + {isLoading && ( + + )}
    ); @@ -16,8 +37,15 @@ Input.propTypes = { children: PropTypes.node, id: PropTypes.string, isRequired: PropTypes.bool, + autoComplete: PropTypes.string, type: PropTypes.string, pattern: PropTypes.string, onChange: PropTypes.func, - list: PropTypes.string + list: PropTypes.string, + min: PropTypes.string, + readOnly: PropTypes.bool, + isLoading: PropTypes.bool, + ariaBusy: PropTypes.bool, + value: PropTypes.string, + testId: PropTypes.string, }; diff --git a/src/hooks/useScrollToSection.js b/src/hooks/useScrollToSection.js new file mode 100644 index 00000000..588fca52 --- /dev/null +++ b/src/hooks/useScrollToSection.js @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +export const useScrollToSection = () => { + const { hash } = useLocation(); + + useEffect(() => { + const sectionId = hash.split('#')[1]; + if (sectionId) { + document.getElementById(sectionId).scrollIntoView(); + } + }, []); +}; diff --git a/src/views/Carte.js b/src/views/Carte.js index c56c97c2..3fa824c6 100644 --- a/src/views/Carte.js +++ b/src/views/Carte.js @@ -1,12 +1,15 @@ import React, { Component } from 'react'; import Header from '../components/Header'; -import '@gouvfr-anct/cartographie-nationale/cartographie'; -const urlPermanences = import.meta.env.VITE_APP_API_URL + '/permanences'; +import '@gouvfr-anct/cartographie-nationale/cartographie'; +import '../../public/styles-5.22.0.css'; class Carte extends Component { render() { + const urlPermanences = import.meta.env.VITE_APP_API_URL + '/permanences'; + window.scrollTo({ top: 0 }); + return (
    diff --git a/src/views/candidature-conseiller/AddressChooser.jsx b/src/views/candidature-conseiller/AddressChooser.jsx index 32c7e7c7..194357a0 100644 --- a/src/views/candidature-conseiller/AddressChooser.jsx +++ b/src/views/candidature-conseiller/AddressChooser.jsx @@ -1,24 +1,34 @@ -import React from 'react'; +import React, { useState } from 'react'; import Input from '../../components/commun/Input'; import { useGeoApi } from './useGeoApi'; import { debounce } from './debounce'; export default function AddressChooser() { - const { search, villes } = useGeoApi(); + const { searchByName, villes } = useGeoApi(); + const [codeCommune, setCodeCommune] = useState(''); return ( <> search(event.target.value))} + onChange={debounce(async event => { + searchByName(event.target.value); + const codeCommune = await villes.find(({ codesPostaux, nom }) => + (`${codesPostaux[0]} ${nom}`).toUpperCase() === (event.target.value).toUpperCase())?.code; + setCodeCommune(codeCommune); + })} > - Votre lieu d’habitation Saississez le nom ou le code postal de votre commune. + Votre lieu d’habitation *{' '} + Saississez le nom ou le code postal de votre commune. + - {villes.map(({ code, nom }) => ( - + {villes.map(({ codesPostaux, nom }, key) => ( + ))} diff --git a/src/views/candidature-conseiller/CandidatureConseiller.css b/src/views/candidature-conseiller/CandidatureConseiller.css index ce92016b..430a0c05 100644 --- a/src/views/candidature-conseiller/CandidatureConseiller.css +++ b/src/views/candidature-conseiller/CandidatureConseiller.css @@ -2,6 +2,16 @@ border: 1px solid var(--grey-900-175); } +.cc-section legend { + float: left; + padding: 0; + width: 100%; +} + +.cc-section legend+* { + clear: left; +} + .cc-titre { color: var(--blue-france-sun-113-625); } @@ -27,3 +37,7 @@ display: flex; justify-content: center; } + +html { + scroll-behavior: smooth; +} diff --git a/src/views/candidature-conseiller/CandidatureConseiller.jsx b/src/views/candidature-conseiller/CandidatureConseiller.jsx index 9c91b4c7..ed0f3a67 100644 --- a/src/views/candidature-conseiller/CandidatureConseiller.jsx +++ b/src/views/candidature-conseiller/CandidatureConseiller.jsx @@ -1,24 +1,69 @@ -import React, { useState } from 'react'; -import Sommaire from './Sommaire'; +import React, { useState, useEffect } from 'react'; +import SommaireConseiller from './SommaireConseiller'; import InformationsDeContact from './InformationsDeContact'; import SituationEtExperience from './SituationEtExperience'; import Disponibilite from './Disponibilite'; import Motivation from './Motivation'; import EnResume from './EnResume'; +import Alert from '../../components/commun/Alert'; +import Captcha from '../../components/commun/Captcha'; +import { useScrollToSection } from '../../hooks/useScrollToSection'; +import { useNavigate } from 'react-router-dom'; +import { useApiAdmin } from './useApiAdmin'; + +import '@gouvfr/dsfr/dist/component/form/form.min.css'; +import '@gouvfr/dsfr/dist/component/input/input.min.css'; +import '@gouvfr/dsfr/dist/component/checkbox/checkbox.min.css'; +import '@gouvfr/dsfr/dist/component/radio/radio.min.css'; +import '@gouvfr/dsfr/dist/component/badge/badge.min.css'; +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 './CandidatureConseiller.css'; -import { situations } from './situations'; export default function CandidatureConseiller() { - const [dateDisponibilite, setDateDisponibilite] = useState(); + const [dateDisponibilite, setDateDisponibilite] = useState(''); const [isSituationValid, setIsSituationValid] = useState(true); - const [situationChecks, setSituationChecks] = useState( - new Array(situations.length).fill(false) - ); + const [validationError, setValidationError] = useState(''); + const { buildConseillerData, creerCandidatureConseiller } = useApiAdmin(); + + const navigate = useNavigate(); + useScrollToSection(); + + useEffect(() => { + document.title = 'Conseiller numérique - Devenir conseiller numérique'; + }, []); + + const estSituationRemplie = formData => { + const demandeurEmploi = formData.get('estDemandeurEmploi') === 'on'; + const enEmploi = formData.get('estEnEmploi') === 'on'; + const enFormation = formData.get('estEnFormation') === 'on'; + const diplome = formData.get('estDiplomeMedNum') === 'on'; + + return demandeurEmploi || enEmploi || enFormation || diplome; + }; + + const validerLaCandidature = async event => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); - const valider = () => { - setIsSituationValid(situationChecks.some(checked => checked)); - if (!isSituationValid) { - document.getElementById('situationEtExperience').scrollIntoView(); + if (!estSituationRemplie(formData)) { + setIsSituationValid(false); + document.getElementById('situation-et-experience').scrollIntoView(); + } else { + const conseillerData = await buildConseillerData(formData); + const resultatCreation = await creerCandidatureConseiller(conseillerData); + if (resultatCreation?.status >= 400) { + const error = await resultatCreation.json(); + setValidationError(error.message); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else if (!resultatCreation.status) { + setValidationError(resultatCreation.message); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + navigate('/candidature-validee-conseiller'); + } } }; @@ -26,22 +71,31 @@ export default function CandidatureConseiller() {
    - +

    Je veux devenir conseiller numérique

    Les champs avec * sont obligatoires.

    -
    + {validationError && +
    + + {validationError} + +
    + } + - + - diff --git a/src/views/candidature-conseiller/CandidatureConseiller.test.jsx b/src/views/candidature-conseiller/CandidatureConseiller.test.jsx index 1adeecf2..ee73c213 100644 --- a/src/views/candidature-conseiller/CandidatureConseiller.test.jsx +++ b/src/views/candidature-conseiller/CandidatureConseiller.test.jsx @@ -1,37 +1,42 @@ -import { render, screen, within, fireEvent } from '@testing-library/react'; +import { render, screen, within, fireEvent, act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import CandidatureConseiller from './CandidatureConseiller'; -import { textMatcher } from './test-utils'; +import { textMatcher, dateDujour } from '../../../test/test-utils'; +import * as ReactRouterDom from 'react-router-dom'; +import * as useApiAdmin from './useApiAdmin'; + +vi.mock('react-router-dom', () => ({ + useLocation: () => ({ hash: '' }), + useNavigate: vi.fn() +})); describe('candidature conseiller', () => { - describe('étant un candidat', () => { - it('quand j’affiche le formulaire alors le titre et le menu s’affichent', () => { - // WHEN - render(); + it('quand j’affiche le formulaire alors le titre et le menu s’affichent', () => { + // WHEN + render(); - // THEN - const titre = screen.getByRole('heading', { level: 1, name: 'Je veux devenir conseiller numérique' }); - expect(titre).toBeInTheDocument(); + // THEN + const titre = screen.getByRole('heading', { level: 1, name: 'Je veux devenir conseiller numérique' }); + expect(titre).toBeInTheDocument(); - const champsObligatoires = screen.getByText(textMatcher('Les champs avec * sont obligatoires.'), { selector: 'p' }); - expect(champsObligatoires).toBeInTheDocument(); + const champsObligatoires = screen.getByText(textMatcher('Les champs avec * sont obligatoires.'), { selector: 'p' }); + expect(champsObligatoires).toBeInTheDocument(); - const navigation = screen.getByRole('navigation', { name: 'Sommaire' }); - const menu = within(navigation).getByRole('list'); - const menuItems = within(menu).getAllByRole('listitem'); + const navigation = screen.getByRole('navigation', { name: 'Sommaire' }); + const menu = within(navigation).getByRole('list'); + const menuItems = within(menu).getAllByRole('listitem'); - const informationsDeContact = within(menuItems[0]).getByRole('link', { name: 'Vos informations de contact' }); - expect(informationsDeContact).toHaveAttribute('href', '#informationsDeContact'); + const informationsDeContact = within(menuItems[0]).getByRole('link', { name: 'Vos informations de contact' }); + expect(informationsDeContact).toHaveAttribute('href', '#informations-de-contact'); - const situationEtExperience = within(menuItems[1]).getByRole('link', { name: 'Votre situation et expérience' }); - expect(situationEtExperience).toHaveAttribute('href', '#situationEtExperience'); + const situationEtExperience = within(menuItems[1]).getByRole('link', { name: 'Votre situation et expérience' }); + expect(situationEtExperience).toHaveAttribute('href', '#situation-et-experience'); - const votreDisponibilite = within(menuItems[2]).getByRole('link', { name: 'Votre disponibilité' }); - expect(votreDisponibilite).toHaveAttribute('href', '#votreDisponibilite'); + const votreDisponibilite = within(menuItems[2]).getByRole('link', { name: 'Votre disponibilité' }); + expect(votreDisponibilite).toHaveAttribute('href', '#votre-disponibilite'); - const votreMotivation = within(menuItems[3]).getByRole('link', { name: 'Votre motivation' }); - expect(votreMotivation).toHaveAttribute('href', '#votreMotivation'); - }); + const votreMotivation = within(menuItems[3]).getByRole('link', { name: 'Votre motivation' }); + expect(votreMotivation).toHaveAttribute('href', '#votre-motivation'); }); it('quand j’affiche le formulaire alors l’étape "Vos informations de contact" est affiché', () => { @@ -41,7 +46,7 @@ describe('candidature conseiller', () => { // THEN const formulaire = screen.getByRole('form', { name: 'Candidature conseiller' }); const etapeInformationsDeContact = within(formulaire).getByRole('group', { name: 'Vos informations de contact' }); - expect(etapeInformationsDeContact).toHaveAttribute('id', 'informationsDeContact'); + expect(etapeInformationsDeContact).toHaveAttribute('id', 'informations-de-contact'); const prenom = within(etapeInformationsDeContact).getByLabelText('Prénom *'); expect(prenom).toHaveAttribute('type', 'text'); @@ -55,12 +60,15 @@ describe('candidature conseiller', () => { expect(email).toHaveAttribute('type', 'email'); expect(email).toBeRequired(); - const telephone = within(etapeInformationsDeContact).getByLabelText('Téléphone Format attendu : 0122334455'); + const telephone = within(etapeInformationsDeContact).getByLabelText('Téléphone Format attendu : +33122334455'); expect(telephone).toHaveAttribute('type', 'tel'); - expect(telephone).toHaveAttribute('pattern', '0[1-9]{9}'); + expect(telephone).toHaveAttribute('pattern', '[+](33|590|596|594|262|269|687)[0-9]{9}'); - const habitation = within(etapeInformationsDeContact).getByLabelText('Votre lieu d’habitation Saississez le nom ou le code postal de votre commune.'); + const habitation = within(etapeInformationsDeContact).getByLabelText(('Votre lieu d’habitation * Saississez le nom ou le code postal de votre commune.')); expect(habitation).toHaveAttribute('type', 'text'); + + const habitationCodeCommune = within(etapeInformationsDeContact).getByTestId('lieuHabitationCodeCommune-hidden'); + expect(habitationCodeCommune).toHaveAttribute('id', 'lieuHabitationCodeCommune'); }); it('quand j’affiche le formulaire alors l’étape "Votre situation et expérience" est affiché', () => { @@ -70,13 +78,14 @@ describe('candidature conseiller', () => { // THEN const formulaire = screen.getByRole('form', { name: 'Candidature conseiller' }); const situationEtExperience = within(formulaire).getByRole('group', { name: 'Votre situation et expérience' }); - expect(situationEtExperience).toHaveAttribute('id', 'situationEtExperience'); + expect(situationEtExperience).toHaveAttribute('id', 'situation-et-experience'); const situation = within(situationEtExperience).getByText(textMatcher('Êtes-vous actuellement dans l’une des situations suivantes ? *'), { selector: 'p' }); expect(situation).toBeInTheDocument(); const demandeurEmploi = screen.getByRole('checkbox', { name: 'Demandeur d’emploi' }); expect(demandeurEmploi).toBeInTheDocument(); + expect(demandeurEmploi).not.toBeRequired(); const enEmploi = screen.getByRole('checkbox', { name: 'En emploi' }); expect(enEmploi).toBeInTheDocument(); @@ -101,14 +110,16 @@ describe('candidature conseiller', () => { const oui = screen.getByRole('radio', { name: 'Oui' }); expect(oui).toBeRequired(); - expect(oui).toHaveAttribute('name', 'experienceProfessionnelle'); + expect(oui).toHaveAttribute('name', 'aUneExperienceMedNum'); + expect(oui).toHaveAttribute('value', 'oui'); const non = screen.getByRole('radio', { name: 'Non' }); expect(non).toBeRequired(); - expect(non).toHaveAttribute('name', 'experienceProfessionnelle'); + expect(non).toHaveAttribute('name', 'aUneExperienceMedNum'); + expect(non).toHaveAttribute('value', 'non'); }); - it('quand je coche "diplomé", un champ pour préciser le diplôme s’affiche', () => { + it('quand je coche "diplomé" alors un champ pour préciser le diplôme s’affiche', () => { // GIVEN render(); @@ -131,7 +142,7 @@ describe('candidature conseiller', () => { // THEN const formulaire = screen.getByRole('form', { name: 'Candidature conseiller' }); const votreDisponibilite = within(formulaire).getByRole('group', { name: 'Votre disponibilité' }); - expect(votreDisponibilite).toHaveAttribute('id', 'votreDisponibilite'); + expect(votreDisponibilite).toHaveAttribute('id', 'votre-disponibilite'); const questionDisponibilite = within(votreDisponibilite).getByText( textMatcher('À quel moment êtes-vous prêt(e) à démarrer votre mission et la formation de conseiller numérique ? *'), @@ -147,6 +158,7 @@ describe('candidature conseiller', () => { const date = within(votreDisponibilite).getByLabelText('Choisir une date'); expect(date).toHaveAttribute('type', 'date'); + expect(date).toHaveAttribute('id', 'dateDisponibilite'); expect(date).toBeRequired(); const questionDeplacement = within(votreDisponibilite).getByText( @@ -163,31 +175,38 @@ describe('candidature conseiller', () => { const _5km = screen.getByRole('radio', { name: '5 km' }); expect(_5km).toBeRequired(); - expect(_5km).toHaveAttribute('name', 'distanceDomicile'); + expect(_5km).toHaveAttribute('name', 'distanceMax'); + expect(_5km).toHaveAttribute('id', '5'); const _10km = screen.getByRole('radio', { name: '10 km' }); expect(_10km).toBeRequired(); - expect(_10km).toHaveAttribute('name', 'distanceDomicile'); + expect(_10km).toHaveAttribute('name', 'distanceMax'); + expect(_10km).toHaveAttribute('id', '10'); const _15km = screen.getByRole('radio', { name: '15 km' }); expect(_15km).toBeRequired(); - expect(_15km).toHaveAttribute('name', 'distanceDomicile'); + expect(_15km).toHaveAttribute('name', 'distanceMax'); + expect(_15km).toHaveAttribute('id', '15'); const _20km = screen.getByRole('radio', { name: '20 km' }); expect(_20km).toBeRequired(); - expect(_20km).toHaveAttribute('name', 'distanceDomicile'); + expect(_20km).toHaveAttribute('name', 'distanceMax'); + expect(_20km).toHaveAttribute('id', '20'); const _40km = screen.getByRole('radio', { name: '40 km' }); expect(_40km).toBeRequired(); - expect(_40km).toHaveAttribute('name', 'distanceDomicile'); + expect(_40km).toHaveAttribute('name', 'distanceMax'); + expect(_40km).toHaveAttribute('id', '40'); const _100km = screen.getByRole('radio', { name: '100 km' }); expect(_100km).toBeRequired(); - expect(_100km).toHaveAttribute('name', 'distanceDomicile'); + expect(_100km).toHaveAttribute('name', 'distanceMax'); + expect(_100km).toHaveAttribute('id', '100'); const franceEntiere = screen.getByRole('radio', { name: 'France entière' }); expect(franceEntiere).toBeRequired(); - expect(franceEntiere).toHaveAttribute('name', 'distanceDomicile'); + expect(franceEntiere).toHaveAttribute('name', 'distanceMax'); + expect(franceEntiere).toHaveAttribute('id', '2000'); }); it('quand j’affiche le formulaire alors l’étape "Votre motivation" est affiché', () => { @@ -197,7 +216,7 @@ describe('candidature conseiller', () => { // THEN const formulaire = screen.getByRole('form', { name: 'Candidature conseiller' }); const votreMotivation = within(formulaire).getByRole('group', { name: 'Votre motivation' }); - expect(votreMotivation).toHaveAttribute('id', 'votreMotivation'); + expect(votreMotivation).toHaveAttribute('id', 'votre-motivation'); const aideMotivation = within(votreMotivation).getByText( textMatcher('En quelques lignes, décrivez votre motivation personnelle pour devenir conseiller numérique ' + @@ -207,7 +226,7 @@ describe('candidature conseiller', () => { expect(aideMotivation).toBeInTheDocument(); const descriptionMotivation = within(votreMotivation).getByLabelText('Votre message *'); - expect(descriptionMotivation).toHaveAttribute('name', 'descriptionMotivation'); + expect(descriptionMotivation).toHaveAttribute('name', 'motivation'); expect(descriptionMotivation).toBeRequired(); }); @@ -216,7 +235,7 @@ describe('candidature conseiller', () => { render(); // THEN - const enResume = screen.getByText(textMatcher('En résumé'), { selector: 'p' }); + const enResume = screen.getByText('En résumé', { selector: 'p' }); expect(enResume).toBeInTheDocument(); const titreResume = screen.getByText( @@ -234,14 +253,16 @@ describe('candidature conseiller', () => { expect(descriptionResume).toBeInTheDocument(); }); - it('quand je modifie la date de disponibilité, elle s’affiche dans l’encart "En résumé" est affiché', () => { + it('quand je modifie la date de disponibilité, alors elle s’affiche dans l’encart "En résumé"', () => { // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); render(); - const dateDisponibilite = '2023-12-12'; + const date = dateDujour(); // WHEN - const date = screen.getByLabelText('Choisir une date'); - fireEvent.change(date, { target: { value: dateDisponibilite } }); + const dateDisponibilite = screen.getByLabelText('Choisir une date'); + fireEvent.change(dateDisponibilite, { target: { value: date } }); // THEN const titreResume = screen.getByText( @@ -249,62 +270,442 @@ describe('candidature conseiller', () => { { selector: 'p' } ); expect(titreResume).toBeInTheDocument(); + vi.useRealTimers(); }); it('quand je renseigne un début de nom de ville qui existe alors plusieurs résultats sont affichés', async () => { // GIVEN render(); - const geoApiResponse = [ - { - code: '75001', - nom: 'Paris', + const geoApiResponse = { + 'type': 'FeatureCollection', + 'version': 'draft', + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [2.347, 48.859] + }, + 'properties': { + 'label': 'Paris', + 'score': 0.730668760330579, + 'id': '75056', + 'type': 'municipality', + 'name': 'Paris', + 'postcode': '75001', + 'citycode': '75056', + 'x': 652089.7, + 'y': 6862305.26, + 'population': 2133111, + 'city': 'Paris', + 'context': '75, Paris, Île-de-France', + 'importance': 0.67372, + 'municipality': 'Paris' + } + }, + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [1.869755, 44.253003] + }, + 'properties': { + 'label': 'Parisot', + 'score': 0.932836363636364, + 'id': '82137', + 'banId': '4e195f30-96f0-47c8-82e9-2968b067bccc', + 'type': 'municipality', + 'name': 'Parisot', + 'postcode': '82160', + 'citycode': '82137', + 'x': 609752.79, + 'y': 6351088.82, + 'population': 554, + 'city': 'Parisot', + 'context': '82, Tarn-et-Garonne, Occitanie', + 'importance': 0.2612, + 'municipality': 'Parisot' + } + } + ], + 'attribution': 'BAN', + 'licence': 'ETALAB-2.0', + 'query': 'paris 75002', + 'filters': { + 'type': 'municipality' }, - { - code: '82137', - nom: 'Parisot', - }, - ]; + 'limit': 5 + }; - vi.spyOn(global, 'fetch').mockResolvedValue({ - json: () => Promise.resolve(geoApiResponse) + vi.spyOn(global, 'fetch').mockResolvedValueOnce({ + json: async () => Promise.resolve(geoApiResponse) }); // WHEN - const adresse = screen.getByLabelText('Votre lieu d’habitation Saississez le nom ou le code postal de votre commune.'); + const adresse = screen.getByLabelText('Votre lieu d’habitation * Saississez le nom ou le code postal de votre commune.'); fireEvent.change(adresse, { target: { value: 'par' } }); // THEN - const paris = await screen.findByText(textMatcher('75001 Paris'), { selector: 'option' }); - const parisot = await screen.findByText(textMatcher('82137 Parisot'), { selector: 'option' }); + const paris = await screen.findByRole('option', { name: '75001 Paris', hidden: true }); + const parisot = await screen.findByRole('option', { name: '82160 Parisot', hidden: true }); expect(paris).toBeInTheDocument(); expect(parisot).toBeInTheDocument(); }); it('quand je coche au moins une case de situation et que je valide le formulaire alors il n’y a pas d’erreur de validation', () => { // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 200, json: async () => Promise.resolve({}) })) + ); + + const mockNavigate = vi.fn().mockReturnValue(() => { }); + vi.spyOn(ReactRouterDom, 'useNavigate').mockReturnValue(mockNavigate); + render(); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const email = screen.getByLabelText('Adresse e-mail * Format attendu : nom@domaine.fr'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const adresse = screen.getByLabelText('Votre lieu d’habitation * Saississez le nom ou le code postal de votre commune.'); + fireEvent.change(adresse, { target: { value: '93100 Montreuil' } }); const enEmploi = screen.getByRole('checkbox', { name: 'En emploi' }); fireEvent.click(enEmploi); + const oui = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(oui); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const _5km = screen.getByRole('radio', { name: '5 km' }); + fireEvent.click(_5km); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); // WHEN - const envoyer = screen.getByRole('button', { type: 'submit' }); + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); fireEvent.click(envoyer); // THEN - const erreurCheckboxes = screen.queryByText(textMatcher('Vous devez cocher au moins une case'), { selector: 'p' }); + const erreurCheckboxes = screen.queryByText('Vous devez cocher au moins une case', { selector: 'p' }); expect(erreurCheckboxes).not.toBeInTheDocument(); + vi.useRealTimers(); }); it('quand je ne coche pas de case de situation et que je valide le formulaire alors il y a une erreur de validation', () => { // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 200, json: async () => Promise.resolve({}) })) + ); + render(); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const email = screen.getByLabelText('Adresse e-mail * Format attendu : nom@domaine.fr'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const adresse = screen.getByLabelText('Votre lieu d’habitation * Saississez le nom ou le code postal de votre commune.'); + fireEvent.change(adresse, { target: { value: '93100 Montreuil' } }); + const oui = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(oui); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const _5km = screen.getByRole('radio', { name: '5 km' }); + fireEvent.click(_5km); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); // WHEN - const envoyer = screen.getByRole('button', { type: 'submit' }); + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); fireEvent.click(envoyer); // THEN - const erreurCheckboxes = screen.getByText(textMatcher('Vous devez cocher au moins une case'), { selector: 'p' }); + const erreurCheckboxes = screen.getByText('Vous devez cocher au moins une case', { selector: 'p' }); expect(erreurCheckboxes).toBeInTheDocument(); + vi.useRealTimers(); + }); + + it('quand je remplis le formulaire, que je l’envoie et que le serveur me renvoie une erreur, alors elle s’affiche sur la page', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 400, json: async () => Promise.resolve({ message: 'Cette adresse mail est déjà utilisée' }) })) + ); + + render(); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const email = screen.getByLabelText('Adresse e-mail * Format attendu : nom@domaine.fr'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const adresse = screen.getByLabelText('Votre lieu d’habitation * Saississez le nom ou le code postal de votre commune.'); + fireEvent.change(adresse, { target: { value: '93100 Montreuil' } }); + const enEmploi = screen.getByRole('checkbox', { name: 'En emploi' }); + fireEvent.click(enEmploi); + const oui = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(oui); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const _5km = screen.getByRole('radio', { name: '5 km' }); + fireEvent.click(_5km); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + // THEN + const titreErreurValidation = screen.getByRole('heading', { level: 3, name: 'Erreur de validation' }); + expect(titreErreurValidation).toBeInTheDocument(); + const contenuErreurValidation = screen.getByText('Cette adresse mail est déjà utilisée', { selector: 'p' }); + expect(contenuErreurValidation).toBeInTheDocument(); + vi.useRealTimers(); + }); + + it('quand je remplis le formulaire avec toutes les informations valides, alors je suis redirigé vers la page de candidature validée', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 200, json: async () => Promise.resolve({}) })) + ); + + const mockNavigate = vi.fn().mockReturnValue(() => { }); + vi.spyOn(ReactRouterDom, 'useNavigate').mockReturnValue(mockNavigate); + + render(); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const email = screen.getByLabelText('Adresse e-mail * Format attendu : nom@domaine.fr'); + const adresse = screen.getByLabelText('Votre lieu d’habitation * Saississez le nom ou le code postal de votre commune.'); + fireEvent.change(adresse, { target: { value: '93100 Montreuil' } }); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const enEmploi = screen.getByRole('checkbox', { name: 'En emploi' }); + fireEvent.click(enEmploi); + const oui = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(oui); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const _5km = screen.getByRole('radio', { name: '5 km' }); + fireEvent.click(_5km); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + // THEN + expect(mockNavigate).toHaveBeenCalledWith('/candidature-validee-conseiller'); + + vi.useRealTimers(); + }); + + it('quand je remplis complètement le formulaire avec un numéro téléphone valide, alors je suis redirigé vers la page de candidature validée', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 200, json: async () => Promise.resolve({}) })) + ); + + const mockNavigate = vi.fn().mockReturnValue(() => { }); + vi.spyOn(ReactRouterDom, 'useNavigate').mockReturnValue(mockNavigate); + + render(); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const email = screen.getByLabelText('Adresse e-mail * Format attendu : nom@domaine.fr'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const adresse = screen.getByLabelText('Votre lieu d’habitation * Saississez le nom ou le code postal de votre commune.'); + fireEvent.change(adresse, { target: { value: '93100 Montreuil' } }); + const telephone = screen.getByLabelText('Téléphone Format attendu : +33122334455'); + fireEvent.change(telephone, { target: { value: '+33159590730' } }); + const enEmploi = screen.getByRole('checkbox', { name: 'En emploi' }); + fireEvent.click(enEmploi); + const oui = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(oui); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const _5km = screen.getByRole('radio', { name: '5 km' }); + fireEvent.click(_5km); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + // THEN + expect(mockNavigate).toHaveBeenCalledWith('/candidature-validee-conseiller'); + + vi.useRealTimers(); + }); + + it('quand je valide le formulaire alors j’envoie toute les données nescessaires', async () => { + // GIVEN + const formData = [ + [ + 'prenom', + 'Jean' + ], + [ + 'nom', + 'Dupont' + ], + [ + 'email', + 'jean.dupont@example.com' + ], + [ + 'telephone', + '' + ], + [ + 'lieuHabitation', + '93100 Montreuil' + ], + [ + 'lieuHabitationCodeCommune', + '93048' + ], + [ + 'estDemandeurEmploi', + 'on' + ], + [ + 'aUneExperienceMedNum', + 'oui' + ], + [ + 'dateDisponibilite', + '2023-12-12' + ], + [ + 'distanceMax', + '5' + ], + [ + 'motivation', + 'je suis motivé !' + ], + [ + 'g-recaptcha-response', + '1' + ], + [ + 'h-captcha-response', + '1' + ] + ]; + const { buildConseillerData } = renderHook(() => useApiAdmin.useApiAdmin()).result.current; + + //WHEN + const result = await buildConseillerData(formData); + + // THEN + expect(result).toBe(JSON.stringify({ + 'prenom': 'Jean', + 'nom': 'Dupont', + 'email': 'jean.dupont@example.com', + 'telephone': '', + 'estDemandeurEmploi': true, + 'aUneExperienceMedNum': true, + 'dateDisponibilite': '2023-12-12', + 'distanceMax': '5', + 'motivation': 'je suis motivé !', + 'h-captcha-response': '1', + 'estEnEmploi': false, + 'estEnFormation': false, + 'estDiplomeMedNum': false, + 'nomCommune': 'Montreuil', + 'codePostal': '93100', + 'codeCommune': '93048', + 'location': { + 'type': 'Point', + 'coordinates': [2.4491, + 48.8637] + }, + 'codeDepartement': '93', + 'codeRegion': '11', + 'codeCom': null, + })); + + vi.useRealTimers(); + }); + + it('quand je remplis le formulaire et qu’une erreur se produit alors un message d’erreur s’affiche', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.spyOn(useApiAdmin, 'useApiAdmin').mockImplementation(() => ({ + creerCandidatureConseiller: vi.fn().mockReturnValue({ message: 'Failed to fetch' }), + buildConseillerData: vi.fn(), + })); + + render(); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const email = screen.getByLabelText('Adresse e-mail * Format attendu : nom@domaine.fr'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const adresse = screen.getByLabelText('Votre lieu d’habitation * Saississez le nom ou le code postal de votre commune.'); + fireEvent.change(adresse, { target: { value: '93100 Montreuil' } }); + const telephone = screen.getByLabelText('Téléphone Format attendu : +33122334455'); + fireEvent.change(telephone, { target: { value: '+33159590730' } }); + const enEmploi = screen.getByRole('checkbox', { name: 'En emploi' }); + fireEvent.click(enEmploi); + const oui = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(oui); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const _5km = screen.getByRole('radio', { name: '5 km' }); + fireEvent.click(_5km); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + // THEN + const contenuErreurValidation = screen.getByText('Failed to fetch', { selector: 'p' }); + expect(contenuErreurValidation).toBeInTheDocument(); + + vi.useRealTimers(); }); }); diff --git a/src/views/candidature-conseiller/Disponibilite.jsx b/src/views/candidature-conseiller/Disponibilite.jsx index 2cda5a60..f0f268fe 100644 --- a/src/views/candidature-conseiller/Disponibilite.jsx +++ b/src/views/candidature-conseiller/Disponibilite.jsx @@ -4,8 +4,10 @@ import Datepicker from '../../components/commun/Datepicker'; import PropTypes from 'prop-types'; export default function Disponibilite({ setDateDisponibilite }) { + const dateDuJour = new Date().toISOString().slice(0, 10); + return ( -
    +
    Votre disponibilité

    @@ -14,7 +16,7 @@ export default function Disponibilite({ setDateDisponibilite }) {

    Accompagnement de personnes vers l’autonomie dans leurs usages de technologies, services et médias numériques.

    - setDateDisponibilite(event.target.value)}> + setDateDisponibilite(event.target.value)} min={dateDuJour}> Choisir une date
    @@ -24,27 +26,27 @@ export default function Disponibilite({ setDateDisponibilite }) {

    Distance à partir de votre lieu d’habitation

    - + 5 km - + 10 km - + 15 km - + 20 km
    - + 40 km - + 100 km - + France entière
    diff --git a/src/views/candidature-conseiller/EnResume.jsx b/src/views/candidature-conseiller/EnResume.jsx index c9603a41..c38cbf3f 100644 --- a/src/views/candidature-conseiller/EnResume.jsx +++ b/src/views/candidature-conseiller/EnResume.jsx @@ -5,20 +5,20 @@ import PropTypes from 'prop-types'; export default function EnResume({ dateDisponibilite }) { const formatDate = () => { - if (!dateDisponibilite) { + if (dateDisponibilite === '') { return '[Renseignez votre date de disponibilité]'; } - return new Date(dateDisponibilite).toLocaleDateString(); + return new Date(dateDisponibilite).toLocaleDateString('fr-FR'); }; return ( -
    +
    En résumé -

    +

    Vous recherchez une certification et un emploi de conseiller numérique à partir du {formatDate(dateDisponibilite)}.

    -

    +

    Votre choix vous engage à transmettre vos coordonnées, répondre aux sollicitations des{' '} structures accueillantes, vous présenter aux entretiens, et accepter de fournir des{' '} éléments administratifs pour finaliser votre dossier de candidature. diff --git a/src/views/candidature-conseiller/InformationsDeContact.jsx b/src/views/candidature-conseiller/InformationsDeContact.jsx index 8ab07adf..c8d0e592 100644 --- a/src/views/candidature-conseiller/InformationsDeContact.jsx +++ b/src/views/candidature-conseiller/InformationsDeContact.jsx @@ -4,7 +4,7 @@ import AddressChooser from './AddressChooser'; export default function InformationsDeContact() { return ( -

    +
    Vos informations de contact
    - Téléphone Format attendu : 0122334455 + Téléphone Format attendu : +33122334455
    diff --git a/src/views/candidature-conseiller/Motivation.jsx b/src/views/candidature-conseiller/Motivation.jsx index 7256cd4c..eec77939 100644 --- a/src/views/candidature-conseiller/Motivation.jsx +++ b/src/views/candidature-conseiller/Motivation.jsx @@ -3,13 +3,13 @@ import ZoneDeTexte from '../../components/commun/ZoneDeTexte'; export default function Motivation() { return ( -
    +
    Votre motivation

    En quelques lignes, décrivez votre motivation personnelle pour devenir conseiller numérique et{' '} aider les personnes à devenir autonomes dans l’utilisation des outils numériques.

    - + Votre message *
    diff --git a/src/views/candidature-conseiller/SituationEtExperience.jsx b/src/views/candidature-conseiller/SituationEtExperience.jsx index bd214bd5..83d5c73d 100644 --- a/src/views/candidature-conseiller/SituationEtExperience.jsx +++ b/src/views/candidature-conseiller/SituationEtExperience.jsx @@ -5,28 +5,22 @@ import Input from '../../components/commun/Input'; import { situations } from './situations'; import PropTypes from 'prop-types'; -export default function SituationEtExperience({ situationChecks, setSituationChecks, isSituationValid }) { +export default function SituationEtExperience({ isSituationValid }) { const [isDiplomeSelected, setIsDiplomeSelected] = useState(false); const handleCheck = event => { - const indexCaseCochee = situations.findIndex(situation => situation.id === event.target.id); - const updatedCheckedState = situationChecks.map((item, index) => - index === indexCaseCochee ? !item : item - ); - setSituationChecks(updatedCheckedState); - - setIsDiplomeSelected(event.target.id === 'diplome' && event.target.checked); + setIsDiplomeSelected(event.target.id === 'estDiplomeMedNum' && event.target.checked); }; return ( -
    +
    Votre situation et expérience

    Êtes-vous actuellement dans l’une des situations suivantes ? *

    - {situations.map(({ id, libelle }, index) => - + {situations.map(({ id, libelle }) => + {libelle} )} @@ -36,8 +30,8 @@ export default function SituationEtExperience({ situationChecks, setSituationChe { isDiplomeSelected && Précisez le nom de votre diplôme, formation certifiante, modules de formation de médiation, numérique /accompagnement au numérique des publics. @@ -47,10 +41,10 @@ export default function SituationEtExperience({ situationChecks, setSituationChe Avez-vous une expérience professionnelle de médiation numérique ? *

    Accompagnement de personnes vers l’autonomie dans leurs usages de technologies, services et médias numériques.

    - + Oui - + Non
    @@ -58,7 +52,5 @@ export default function SituationEtExperience({ situationChecks, setSituationChe } SituationEtExperience.propTypes = { - situationChecks: PropTypes.array, - setSituationChecks: PropTypes.func, isSituationValid: PropTypes.bool }; diff --git a/src/views/candidature-conseiller/SommaireConseiller.jsx b/src/views/candidature-conseiller/SommaireConseiller.jsx new file mode 100644 index 00000000..870bb359 --- /dev/null +++ b/src/views/candidature-conseiller/SommaireConseiller.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Sommaire from '../../components/Sommaire'; + +export default function SommaireConseiller() { + const parties = [ + { + ancre: '#informations-de-contact', + libelle: 'Vos informations de contact' + }, + { + ancre: '#situation-et-experience', + libelle: 'Votre situation et expérience' + }, + { + ancre: '#votre-disponibilite', + libelle: 'Votre disponibilité' + }, + { + ancre: '#votre-motivation', + libelle: 'Votre motivation' + }, + ]; + + return ( + + ); +} diff --git a/src/views/candidature-conseiller/situations.js b/src/views/candidature-conseiller/situations.js index 7e78b17a..324e7fba 100644 --- a/src/views/candidature-conseiller/situations.js +++ b/src/views/candidature-conseiller/situations.js @@ -1,18 +1,18 @@ export const situations = [ { - id: 'demandeurEmploi', + id: 'estDemandeurEmploi', libelle: 'Demandeur d’emploi', }, { - id: 'enEmploi', + id: 'estEnEmploi', libelle: 'En emploi', }, { - id: 'enFormation', + id: 'estEnFormation', libelle: 'En formation', }, { - id: 'diplome', + id: 'estDiplomeMedNum', libelle: 'Diplômé dans le secteur de la médiation numérique (formation certifiante ou non)', } ]; diff --git a/src/views/candidature-conseiller/useApiAdmin.js b/src/views/candidature-conseiller/useApiAdmin.js new file mode 100644 index 00000000..a427800e --- /dev/null +++ b/src/views/candidature-conseiller/useApiAdmin.js @@ -0,0 +1,146 @@ +import { useGeoApi } from './useGeoApi'; + +export const useApiAdmin = () => { + const { getVilleParCode } = useGeoApi(); + + const creerCandidatureConseiller = async conseillerData => { + const baseUrl = import.meta.env.VITE_APP_API_PILOTAGE_URL; + const requestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: conseillerData + }; + + try { + return await fetch(`${baseUrl}/candidature-conseiller`, requestOptions); + } catch (error) { + return error; + } + }; + + const creerCandidatureStructure = async structureData => { + const baseUrl = import.meta.env.VITE_APP_API_PILOTAGE_URL; + const requestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: structureData + }; + + try { + return await fetch(`${baseUrl}/candidature-structure`, requestOptions); + } catch (error) { + return error; + } + }; + + const creerCandidatureCoordinateur = async structureData => { + const baseUrl = import.meta.env.VITE_APP_API_PILOTAGE_URL; + const requestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: structureData + }; + + try { + return await fetch(`${baseUrl}/candidature-structure-coordinateur`, requestOptions); + } catch (error) { + return error; + } + }; + + const convertValueToBoolean = (conseillerData, key) => { + conseillerData[key] = conseillerData[key] === 'on' || conseillerData[key] === 'oui'; + }; + + const getInformationsVille = async codeCommune => { + if (codeCommune) { + return await getVilleParCode(codeCommune); + } + }; + + const handleInformationsVille = async (formulaireData, codeCommune) => { + const informationsVille = (await getInformationsVille(codeCommune)); + formulaireData.nomCommune = informationsVille?.nom; + formulaireData.codePostal = informationsVille?.codesPostaux[0]; + formulaireData.codeCommune = informationsVille?.code; + formulaireData.location = informationsVille?.centre; + formulaireData.codeDepartement = informationsVille?.codeDepartement; + formulaireData.codeRegion = informationsVille?.codeRegion; + formulaireData.codeCom = informationsVille?.codeDepartement === '00' ? informationsVille?.code.substring(0, 3) : null; + return formulaireData; + }; + + const buildConseillerData = async formData => { + const conseillerData = Object.fromEntries(formData); + convertValueToBoolean(conseillerData, 'estDemandeurEmploi'); + convertValueToBoolean(conseillerData, 'estEnEmploi'); + convertValueToBoolean(conseillerData, 'estEnFormation'); + convertValueToBoolean(conseillerData, 'estDiplomeMedNum'); + convertValueToBoolean(conseillerData, 'aUneExperienceMedNum'); + const codeCommune = conseillerData.lieuHabitationCodeCommune; + await handleInformationsVille(conseillerData, codeCommune); + delete conseillerData.lieuHabitation; + delete conseillerData['g-recaptcha-response']; + delete conseillerData['lieuHabitationCodeCommune']; + + return JSON.stringify(conseillerData); + }; + + const handleContact = structureData => { + structureData.contact = { + prenom: structureData.prenom, + nom: structureData.nom, + fonction: structureData.fonction, + email: structureData.email, + telephone: structureData.telephone, + }; + delete structureData.prenom; + delete structureData.nom; + delete structureData.fonction; + delete structureData.email; + delete structureData.telephone; + }; + + const handleInformationsStructure = structureData => { + structureData.nom = structureData.denomination; + delete structureData.denomination; + }; + + const handleAdresse = async (structureData, codeCommune) => { + await handleInformationsVille(structureData, codeCommune); + delete structureData.adresse; + return structureData; + }; + + const buildStructureData = async (formData, geoLocation, codeCommune) => { + const structureData = Object.fromEntries(formData); + structureData.location = geoLocation; + convertValueToBoolean(structureData, 'aIdentifieCandidat'); + handleContact(structureData); + handleInformationsStructure(structureData); + await handleAdresse(structureData, codeCommune); + convertValueToBoolean(structureData, 'confirmationEngagement'); + delete structureData['g-recaptcha-response']; + return JSON.stringify(structureData); + }; + + const buildCoordinateurData = async (formData, geoLocation, codeCommune) => { + const coordinateurData = Object.fromEntries(formData); + handleContact(coordinateurData); + handleInformationsStructure(coordinateurData); + await handleAdresse(coordinateurData, codeCommune); + convertValueToBoolean(coordinateurData, 'aIdentifieCoordinateur'); + convertValueToBoolean(coordinateurData, 'confirmationEngagement'); + delete coordinateurData['g-recaptcha-response']; + return JSON.stringify(coordinateurData); + }; + + return { + buildConseillerData, + buildStructureData, + buildCoordinateurData, + creerCandidatureConseiller, + creerCandidatureStructure, + creerCandidatureCoordinateur, + }; +}; diff --git a/src/views/candidature-conseiller/useGeoApi.js b/src/views/candidature-conseiller/useGeoApi.js index a7b6029b..5cfb21c3 100644 --- a/src/views/candidature-conseiller/useGeoApi.js +++ b/src/views/candidature-conseiller/useGeoApi.js @@ -1,16 +1,34 @@ import { useState } from 'react'; export const useGeoApi = () => { - const urlBase = 'https://geo.api.gouv.fr/communes?limit=10&fields=nom,code&'; - const [villes, setVilles] = useState([]); - const search = async rechercheUtilisateur => { - const url = `${urlBase}&nom=${rechercheUtilisateur}`; + const distinctVilles = villes => [...new Map(villes.map(item => [item.code, item])).values()]; + + const searchByName = async rechercheUtilisateur => { + const baseUrlSearch = new URL('https://api-adresse.data.gouv.fr/search'); + baseUrlSearch.searchParams.set('type', 'municipality'); + const url = `${baseUrlSearch.toString()}&q=${encodeURIComponent(rechercheUtilisateur)}`; const villes = await fetch(url); - const resultat = await villes.json(); - setVilles(resultat); + const communes = await villes.json(); + + const resultat = communes?.features.map(result => ({ + 'nom': result.properties.municipality, + 'code': result.properties.citycode, + 'codesPostaux': [ + result.properties.postcode + ] + })); + const propositionVilles = await distinctVilles(resultat); + setVilles(propositionVilles); + }; + + const getVilleParCode = async codeCommune => { + const baseUrl = new URL(`https://geo.api.gouv.fr/communes/${codeCommune}`); + baseUrl.searchParams.set('fields', 'nom,code,codesPostaux,centre,codeDepartement,codeRegion'); + const ville = await fetch(baseUrl.toString()); + return await ville.json(); }; - return { search, villes }; + return { searchByName, getVilleParCode, villes }; }; diff --git a/src/views/candidature-coordinateur/BesoinEnCoordinateur.jsx b/src/views/candidature-coordinateur/BesoinEnCoordinateur.jsx new file mode 100644 index 00000000..d3990630 --- /dev/null +++ b/src/views/candidature-coordinateur/BesoinEnCoordinateur.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import BoutonRadio from '../../components/commun/BoutonRadio'; +import Datepicker from '../../components/commun/Datepicker'; + +export default function BesoinEnCoordinateur() { + const dateDuJour = new Date().toISOString().slice(0, 10); + + return ( +
    + Votre besoin en coordinateur +
    +

    + Avez-vous déjà identifié un candidat pour le poste de coordinateur de conseiller numérique ?* +

    +

    Si oui, merci d’inviter ce candidat à s’inscrire sur la plateforme Conseiller numérique

    + + Oui + + + Non + +

    Le coordinateur*

    + + Effectuera uniquement des missions de coordination + + + Accompagnera également des publics + +
    +

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

    + + Choisir une date + +
    + ); +} diff --git a/src/views/candidature-coordinateur/CandidatureCoordinateur.jsx b/src/views/candidature-coordinateur/CandidatureCoordinateur.jsx new file mode 100644 index 00000000..b1a72b6f --- /dev/null +++ b/src/views/candidature-coordinateur/CandidatureCoordinateur.jsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from 'react'; +import SommaireCoordinateur from './SommaireCoordinateur'; +import InformationsDeContact from '../candidature-structure/InformationsDeContact'; +import InformationsDeStructure from '../candidature-structure/InformationsDeStructure'; +import BesoinEnCoordinateur from './BesoinEnCoordinateur'; +import Motivation from './Motivation'; +import Engagement from './Engagement'; +import Alert from '../../components/commun/Alert'; +import Captcha from '../../components/commun/Captcha'; +import { useScrollToSection } from '../../hooks/useScrollToSection'; +import { useNavigate } from 'react-router-dom'; +import { useApiAdmin } from '../candidature-conseiller/useApiAdmin'; + +import '@gouvfr/dsfr/dist/component/form/form.min.css'; +import '@gouvfr/dsfr/dist/component/input/input.min.css'; +import '@gouvfr/dsfr/dist/component/checkbox/checkbox.min.css'; +import '@gouvfr/dsfr/dist/component/radio/radio.min.css'; +import '@gouvfr/dsfr/dist/component/badge/badge.min.css'; +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'; + +export default function CandidatureCoordinateur() { + const [geoLocation, setGeoLocation] = useState(null); + const [validationError, setValidationError] = useState(''); + const [codeCommune, setCodeCommune] = useState(''); + const navigate = useNavigate(); + const { buildCoordinateurData, creerCandidatureCoordinateur } = useApiAdmin(); + useScrollToSection(); + + useEffect(() => { + document.title = 'Conseiller numérique - Devenir coordinateur de conseillers numériques'; + }, []); + + const validerLaCandidature = async event => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + const coordinateurData = await buildCoordinateurData(formData, geoLocation, codeCommune); + const resultatCreation = await creerCandidatureCoordinateur(coordinateurData); + if (resultatCreation?.status >= 400) { + const error = await resultatCreation.json(); + setValidationError(error.message); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else if (!resultatCreation.status) { + setValidationError(resultatCreation.message); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + navigate('/candidature-validee-structure'); + } + }; + + return ( +
    +
    +
    + +
    +
    +

    Je souhaite engager un coordinateur pour mes conseillers numériques

    +

    Les champs avec * sont obligatoires.

    + {validationError && +
    + + {validationError} + +
    + } +
    + + + + + +
    + +
    + + +
    +
    +
    + ); +} diff --git a/src/views/candidature-coordinateur/CandidatureCoordinateur.test.jsx b/src/views/candidature-coordinateur/CandidatureCoordinateur.test.jsx new file mode 100644 index 00000000..f166a49f --- /dev/null +++ b/src/views/candidature-coordinateur/CandidatureCoordinateur.test.jsx @@ -0,0 +1,522 @@ +import { render, screen, within, fireEvent, act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import CandidatureCoordinateur from './CandidatureCoordinateur'; +import { textMatcher, dateDujour } from '../../../test/test-utils'; +import * as ReactRouterDom from 'react-router-dom'; +import * as useApiAdmin from '../candidature-conseiller/useApiAdmin'; +import { useEntrepriseFinder } from '../candidature-structure/useEntrepriseFinder'; + +vi.mock('react-router-dom', () => ({ + useLocation: () => ({ hash: '' }), + useNavigate: vi.fn() +})); + +describe('candidature coordinateur', () => { + it('quand j’affiche le formulaire alors le titre et le menu s’affichent', () => { + // WHEN + render(); + + // THEN + const titre = screen.getByRole('heading', { level: 1, name: textMatcher('Je souhaite engager un coordinateur pour mes conseillers numériques') }); + expect(titre).toBeInTheDocument(); + + const champsObligatoires = screen.getByText(textMatcher('Les champs avec * sont obligatoires.'), { selector: 'p' }); + expect(champsObligatoires).toBeInTheDocument(); + + const navigation = screen.getByRole('navigation', { name: 'Sommaire' }); + const menu = within(navigation).getByRole('list'); + const menuItems = within(menu).getAllByRole('listitem'); + + const informationsDeStructure = within(menuItems[0]).getByRole('link', { name: 'Vos informations de structure' }); + expect(informationsDeStructure).toHaveAttribute('href', '#informations-de-structure'); + + const informationsDeContact = within(menuItems[1]).getByRole('link', { name: 'Vos informations de contact' }); + expect(informationsDeContact).toHaveAttribute('href', '#informations-de-contact'); + + const votreBesoinEnCoordinateur = within(menuItems[2]).getByRole('link', { name: 'Votre besoin en coordinateur' }); + expect(votreBesoinEnCoordinateur).toHaveAttribute('href', '#votre-besoin-en-coordinateur'); + + const votreMotivation = within(menuItems[3]).getByRole('link', { name: 'Votre motivation' }); + expect(votreMotivation).toHaveAttribute('href', '#votre-motivation'); + }); + + it('quand j’affiche le formulaire alors l’étape "Vos informations de structure" est affichée', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature coordinateur' }); + const etapeInformationsDeStructure = within(formulaire).getByRole('group', { name: 'Vos informations de structure' }); + expect(etapeInformationsDeStructure).toHaveAttribute('id', 'informations-de-structure'); + + const denomination = within(etapeInformationsDeStructure).getByLabelText('Dénomination *'); + expect(denomination).toHaveAttribute('type', 'text'); + expect(denomination).toBeRequired(); + + const adresse = within(etapeInformationsDeStructure).getByLabelText('Adresse *'); + expect(adresse).toHaveAttribute('type', 'text'); + expect(adresse).toBeRequired(); + + const questionTypeDeStructure = within(etapeInformationsDeStructure).getByText(textMatcher('Votre structure est *'), { selector: 'p' }); + expect(etapeInformationsDeStructure).toHaveAttribute('id', 'informations-de-structure'); + expect(questionTypeDeStructure).toBeInTheDocument(); + + const uneCommune = screen.getByRole('radio', { name: 'Une commune' }); + expect(uneCommune).toBeRequired(); + expect(uneCommune).toHaveAttribute('name', 'type'); + + const unDepartement = screen.getByRole('radio', { name: 'Un département' }); + expect(unDepartement).toBeRequired(); + expect(unDepartement).toHaveAttribute('name', 'type'); + + const uneRegion = screen.getByRole('radio', { name: 'Une région' }); + expect(uneRegion).toBeRequired(); + expect(uneRegion).toHaveAttribute('name', 'type'); + + const unEtablissemntPublic = screen.getByRole('radio', { name: 'Un établissement public de coopération intercommunale' }); + expect(unEtablissemntPublic).toBeRequired(); + expect(unEtablissemntPublic).toHaveAttribute('name', 'type'); + + const uneCollectivite = screen.getByRole('radio', { name: 'Une collectivité à statut particulier' }); + expect(uneCollectivite).toBeRequired(); + expect(uneCollectivite).toHaveAttribute('name', 'type'); + + const unGIP = screen.getByRole('radio', { name: 'Un GIP' }); + expect(unGIP).toBeRequired(); + expect(unGIP).toHaveAttribute('name', 'type'); + + const uneStructurePrivee = screen.getByRole('radio', { name: 'Une structure privée (association, entreprise de l’ESS, fondations)' }); + expect(uneStructurePrivee).toBeRequired(); + expect(uneStructurePrivee).toHaveAttribute('name', 'type'); + }); + + it('quand j’affiche le formulaire alors l’étape "Vos informations de contact" est affichée', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature coordinateur' }); + const etapeInformationsDeContact = within(formulaire).getByRole('group', { name: 'Vos informations de contact' }); + expect(etapeInformationsDeContact).toHaveAttribute('id', 'informations-de-contact'); + + const prenom = within(etapeInformationsDeContact).getByLabelText('Prénom *'); + expect(prenom).toHaveAttribute('type', 'text'); + expect(prenom).toBeRequired(); + + const nom = within(etapeInformationsDeContact).getByLabelText('Nom *'); + expect(nom).toHaveAttribute('type', 'text'); + expect(nom).toBeRequired(); + + const fonction = within(etapeInformationsDeContact).getByLabelText('Fonction *'); + expect(fonction).toHaveAttribute('type', 'text'); + expect(fonction).toBeRequired(); + + const email = within(etapeInformationsDeContact).getByLabelText('Adresse e-mail *'); + expect(email).toHaveAttribute('type', 'email'); + expect(email).toBeRequired(); + + const telephone = within(etapeInformationsDeContact).getByLabelText('Téléphone *'); + expect(telephone).toHaveAttribute('type', 'tel'); + expect(telephone).toHaveAttribute('pattern', '[+](33|590|596|594|262|269|687)[0-9]{9}'); + expect(telephone).toBeRequired(); + }); + + it('quand j’affiche le formulaire alors l’étape "Votre besoin en coordinateur" est affiché', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature coordinateur' }); + const etapeBesoinCoordinateur = within(formulaire).getByRole('group', { name: 'Votre besoin en coordinateur' }); + expect(etapeBesoinCoordinateur).toHaveAttribute('id', 'votre-besoin-en-coordinateur'); + + const identificationCandidat = within(etapeBesoinCoordinateur).getByText( + textMatcher('Avez-vous déjà identifié un candidat pour le poste de coordinateur de conseiller numérique ?*'), + { selector: 'p' } + ); + expect(identificationCandidat).toBeInTheDocument(); + + const sousTitreIdentificationCandidat = + within(etapeBesoinCoordinateur).getByText( + textMatcher('Si oui, merci d’inviter ce candidat à s’inscrire sur la plateforme Conseiller numérique'), + { selector: 'p' } + ); + expect(sousTitreIdentificationCandidat).toBeInTheDocument(); + + const oui = screen.getByRole('radio', { name: 'Oui' }); + expect(oui).toBeRequired(); + expect(oui).toHaveAttribute('name', 'aIdentifieCoordinateur'); + + const non = screen.getByRole('radio', { name: 'Non' }); + expect(non).toBeRequired(); + expect(non).toHaveAttribute('name', 'aIdentifieCoordinateur'); + + const leCoordinateur = within(etapeBesoinCoordinateur).getByText(textMatcher('Le coordinateur*'), { selector: 'p' }); + expect(leCoordinateur).toBeInTheDocument(); + + const coordination = screen.getByRole('radio', { name: 'Effectuera uniquement des missions de coordination' }); + expect(coordination).toBeRequired(); + expect(coordination).toHaveAttribute('name', 'coordinateurTypeContrat'); + + const publics = screen.getByRole('radio', { name: 'Accompagnera également des publics' }); + expect(publics).toBeRequired(); + expect(publics).toHaveAttribute('name', 'coordinateurTypeContrat'); + + const dateAccueilCoordinateur = within(etapeBesoinCoordinateur).getByText( + textMatcher('À partir de quand êtes vous prêt à accueillir votre coordinateur ?*'), + { selector: 'p' } + ); + expect(dateAccueilCoordinateur).toBeInTheDocument(); + + const date = within(etapeBesoinCoordinateur).getByLabelText('Choisir une date'); + expect(date).toHaveAttribute('type', 'date'); + expect(date).toBeRequired(); + }); + + it('quand j’affiche le formulaire alors l’étape "Votre motivation" est affiché', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature coordinateur' }); + const etapeMotivation = within(formulaire).getByRole('group', { name: 'Votre motivation' }); + expect(etapeMotivation).toHaveAttribute('id', 'votre-motivation'); + + const sousTitreVotreMotvation = + within(etapeMotivation).getByText( + '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é.', + { selector: 'p' } + ); + expect(sousTitreVotreMotvation).toBeInTheDocument(); + + const votreMessage = within(etapeMotivation).getByLabelText('Votre message *'); + expect(votreMessage).toHaveAttribute('id', 'motivation'); + expect(votreMessage).toBeRequired(); + + const questionsMotivation = within(etapeMotivation).getByRole('list'); + const questions = within(questionsMotivation).getAllByRole('listitem'); + + const strategieInclusion = within(questions[0]).getByText( + 'Avez-vous mis en place une stratégie d’inclusion numérique ? Si oui, quelles actions menez-vous dans ce cadre ?' + ); + expect(strategieInclusion).toBeInTheDocument(); + const integration = within(questions[1]).getByText('Pourquoi souhaitez-vous intégrer le dispositif Conseiller numérique ?'); + expect(integration).toBeInTheDocument(); + const positionnement = within(questions[2]).getByText('Comment avez vous pensé le positionnement de votre Conseiller numérique ?'); + expect(positionnement).toBeInTheDocument(); + const reflexion = within(questions[3]).getByText( + 'Avez vous réfléchi son positionnement géographique en complémentarité avec le maillage territorial existant ?' + ); + expect(reflexion).toBeInTheDocument(); + }); + + it('quand j’affiche le formulaire alors l’encart des engagements est affiché', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature coordinateur' }); + const titreEngagement = within(formulaire).getByText(textMatcher('En tant que structure accueillante, vous vous engagez à'), { selector: 'p' }); + expect(titreEngagement).toBeInTheDocument(); + + const sousTitreEngagement = within(formulaire).getByText(textMatcher('Assurer que le conseiller réalise des activités visant à:'), { selector: 'p' }); + expect(sousTitreEngagement).toBeInTheDocument(); + + const engagements = screen.getByTestId('votre-engagement'); + + const listDetail = within(engagements).getAllByRole('listitem'); + within(listDetail[0]).getByText('Renforcer le maillage et les synergies territoriales'); + within(listDetail[1]).getByText('Être le relais principal des employeurs, des Conseillers numériques et de l’équipe d’animation nationale'); + within(listDetail[2]).getByText('Imaginer et mettre en place des collaborations sur la base des besoins de la communauté des Conseillers numériques'); + within(listDetail[3]).getByText('Signer dans les 15 jours suivants un contrat avec ce candidat'); + within(listDetail[4]).getByText('Laisser partir le conseiller numérique en formation initiale ou continue'); + within(listDetail[5]).getByText('Mettre à sa disposition les moyens et équipements pour réaliser sa mission (ordinateur, ' + + 'téléphone portable, voiture si nécessaire)'); + + const confirmationEngagement = screen.getByLabelText('Je confirme avoir lu et pris connaissance des conditions d’engagement.*'); + expect(confirmationEngagement).toBeInTheDocument(); + }); + + it('quand j’affiche le formulaire alors le bouton "Envoyer votre candidature" est affiché', () => { + // WHEN + render(); + + //THEN + const formulaire = screen.getByRole('form', { name: 'Candidature coordinateur' }); + within(formulaire).getByRole('button', { name: 'Envoyer votre candidature' }); + }); + + it('quand je remplis le formulaire, que je l’envoie et que le serveur me renvoie une erreur, alors elle s’affiche sur la page', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 400, json: async () => Promise.resolve({ message: 'Cette adresse mail est déjà utilisée' }) })) + ); + + render(); + const siret = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siret, { target: { value: '1234567890123' } }); + const denomination = screen.getByLabelText('Dénomination *'); + fireEvent.change(denomination, { target: { value: 'Entreprise' } }); + const adresse = screen.getByLabelText('Adresse *'); + fireEvent.change(adresse, { target: { value: '75007 Paris' } }); + const typeStructure = screen.getByRole('radio', { name: 'Une commune' }); + fireEvent.click(typeStructure); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const fonction = screen.getByLabelText('Fonction *'); + fireEvent.change(fonction, { target: { value: 'Test' } }); + const email = screen.getByLabelText('Adresse e-mail *'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const telephone = screen.getByLabelText('Téléphone *'); + fireEvent.change(telephone, { target: { value: '+33123456789' } }); + const identificationCandidat = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(identificationCandidat); + const typeMission = screen.getByRole('radio', { name: 'Accompagnera également des publics' }); + fireEvent.click(typeMission); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + const confirmation = screen.getByRole('checkbox', { name: 'Je confirme avoir lu et pris connaissance des conditions d’engagement. *' }); + fireEvent.click(confirmation); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + // THEN + const titreErreurValidation = screen.getByRole('heading', { level: 3, name: 'Erreur de validation' }); + expect(titreErreurValidation).toBeInTheDocument(); + const contenuErreurValidation = screen.getByText('Cette adresse mail est déjà utilisée', { selector: 'p' }); + expect(contenuErreurValidation).toBeInTheDocument(); + vi.useRealTimers(); + }); + + it('quand je remplis le formulaire avec toutes les informations valides, alors je suis redirigé vers la page de candidature validée', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 200, json: async () => Promise.resolve({}) })) + ); + + const mockNavigate = vi.fn().mockReturnValue(() => { }); + vi.spyOn(ReactRouterDom, 'useNavigate').mockReturnValue(mockNavigate); + + render(); + const siret = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siret, { target: { value: '1234567890123' } }); + const denomination = screen.getByLabelText('Dénomination *'); + fireEvent.change(denomination, { target: { value: 'Entreprise' } }); + const adresse = screen.getByLabelText('Adresse *'); + fireEvent.change(adresse, { target: { value: '75007 Paris' } }); + const typeStructure = screen.getByRole('radio', { name: 'Une commune' }); + fireEvent.click(typeStructure); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const fonction = screen.getByLabelText('Fonction *'); + fireEvent.change(fonction, { target: { value: 'Test' } }); + const email = screen.getByLabelText('Adresse e-mail *'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const telephone = screen.getByLabelText('Téléphone *'); + fireEvent.change(telephone, { target: { value: '+33123456789' } }); + const identificationCandidat = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(identificationCandidat); + const typeMission = screen.getByRole('radio', { name: 'Accompagnera également des publics' }); + fireEvent.click(typeMission); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + const confirmation = screen.getByRole('checkbox', { name: 'Je confirme avoir lu et pris connaissance des conditions d’engagement. *' }); + fireEvent.click(confirmation); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + // THEN + expect(mockNavigate).toHaveBeenCalledWith('/candidature-validee-structure'); + + vi.useRealTimers(); + }); + + it('quand je valide le formulaire alors j’envoie toute les données nescessaires', async () => { + // GIVEN + const formData = [ + [ + 'siret', + '13002603200016' + ], + [ + 'denomination', + 'AGENCE NATIONALE DE LA COHESION DES TERRITOIRES' + ], + [ + 'adresse', + '20 AVENUE DE SEGUR, 75007 PARIS' + ], + [ + 'type', + 'COMMUNE' + ], + [ + 'prenom', + 'Jean' + ], + [ + 'nom', + 'Dupont' + ], + [ + 'fonction', + 'Test' + ], + [ + 'email', + 'jean.dupont@example.com' + ], + [ + 'telephone', + '+33123456789' + ], + [ + 'aIdentifieCoordinateur', + 'non' + ], + [ + 'coordinateurTypeContrat', + 'FT' + ], + [ + 'dateDebutMission', + '2023-12-12' + ], + [ + 'motivation', + 'je suis motivé !' + ], + [ + 'confirmationEngagement', + 'on' + ], + [ + 'g-recaptcha-response', + '1' + ], + [ + 'h-captcha-response', + '1' + ] + ]; + + const { buildCoordinateurData } = renderHook(() => useApiAdmin.useApiAdmin()).result.current; + const { getGeoLocationFromAddress } = renderHook(() => useEntrepriseFinder()).result.current; + + // //WHEN + const geoLocation = await getGeoLocationFromAddress('20 AVENUE DE SEGUR, 75007 PARIS'); + const result = await buildCoordinateurData(formData, geoLocation, '75107'); + + // THEN + expect(result).toBe(JSON.stringify({ + 'siret': '13002603200016', + 'type': 'COMMUNE', + 'aIdentifieCoordinateur': false, + 'coordinateurTypeContrat': 'FT', + 'dateDebutMission': '2023-12-12', + 'motivation': 'je suis motivé !', + 'confirmationEngagement': true, + 'h-captcha-response': '1', + 'contact': { + 'prenom': 'Jean', 'nom': 'Dupont', + 'fonction': 'Test', 'email': + 'jean.dupont@example.com', + 'telephone': '+33123456789' + }, + 'nom': 'AGENCE NATIONALE DE LA COHESION DES TERRITOIRES', + 'nomCommune': 'Paris 7e Arrondissement', + 'codePostal': '75007', + 'codeCommune': '75107', + 'location': { 'type': 'Point', 'coordinates': [2.3115, 48.8548] }, + 'codeDepartement': '75', + 'codeRegion': '11', + 'codeCom': null, + })); + + vi.useRealTimers(); + }); + + it('quand je remplis le formulaire et qu’une erreur se produit alors un message d’erreur s’affiche', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.spyOn(useApiAdmin, 'useApiAdmin').mockImplementation(() => ({ + creerCandidatureCoordinateur: vi.fn().mockReturnValue({ message: 'Failed to fetch' }), + buildCoordinateurData: vi.fn(), + })); + + render(); + const siret = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siret, { target: { value: '1234567890123' } }); + const denomination = screen.getByLabelText('Dénomination *'); + fireEvent.change(denomination, { target: { value: 'Entreprise' } }); + const adresse = screen.getByLabelText('Adresse *'); + fireEvent.change(adresse, { target: { value: '75007 Paris' } }); + const typeStructure = screen.getByRole('radio', { name: 'Une commune' }); + fireEvent.click(typeStructure); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const fonction = screen.getByLabelText('Fonction *'); + fireEvent.change(fonction, { target: { value: 'Test' } }); + const email = screen.getByLabelText('Adresse e-mail *'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const telephone = screen.getByLabelText('Téléphone *'); + fireEvent.change(telephone, { target: { value: '+33123456789' } }); + const identificationCandidat = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(identificationCandidat); + const typeMission = screen.getByRole('radio', { name: 'Accompagnera également des publics' }); + fireEvent.click(typeMission); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + const confirmation = screen.getByRole('checkbox', { name: 'Je confirme avoir lu et pris connaissance des conditions d’engagement. *' }); + fireEvent.click(confirmation); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + + // THEN + const contenuErreurValidation = screen.getByText('Failed to fetch', { selector: 'p' }); + expect(contenuErreurValidation).toBeInTheDocument(); + + vi.useRealTimers(); + }); +}); + diff --git a/src/views/candidature-coordinateur/CompanyFinder.jsx b/src/views/candidature-coordinateur/CompanyFinder.jsx new file mode 100644 index 00000000..8df16d4b --- /dev/null +++ b/src/views/candidature-coordinateur/CompanyFinder.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Input from '../../components/commun/Input'; +import { useEntrepriseFinder } from './useEntrepriseFinder'; +import { debounce } from '../candidature-conseiller/debounce'; + +export default function CompanyFinder() { + const { search, entreprise } = useEntrepriseFinder(); + + return ( + <> + search(event.target.value))} + placeholder="N° SIRET / RIDET" + /> + {entreprise} + + ); +} diff --git a/src/views/candidature-coordinateur/Engagement.jsx b/src/views/candidature-coordinateur/Engagement.jsx new file mode 100644 index 00000000..38cf6289 --- /dev/null +++ b/src/views/candidature-coordinateur/Engagement.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Notice from '../../components/commun/Notice'; +import Checkbox from '../../components/commun/Checkbox'; + +export default function Engagement() { + return ( + +

    + En tant que structure accueillante, vous vous engagez à +

    +

    + Assurer que le conseiller réalise des activités visant à: +

    +
      +
    • Renforcer le maillage et les synergies territoriales
    • +
    • Être le relais principal des employeurs, des Conseillers numériques et de l’équipe d’animation nationale
    • +
    • Imaginer et mettre en place des collaborations sur la base des besoins de la communauté des Conseillers numériques
    • +
    • Signer dans les 15 jours suivants un contrat avec ce candidat
    • +
    • Laisser partir le conseiller numérique en formation initiale ou continue
    • +
    • Mettre à sa disposition les moyens et équipements pour réaliser sa mission (ordinateur, téléphone portable, voiture si nécessaire)
    • +
    + + Je confirme avoir lu et pris connaissance des conditions d’engagement.* + +
    + ); +} diff --git a/src/views/candidature-coordinateur/Motivation.jsx b/src/views/candidature-coordinateur/Motivation.jsx new file mode 100644 index 00000000..38bc2eae --- /dev/null +++ b/src/views/candidature-coordinateur/Motivation.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import ZoneDeTexte from '../../components/commun/ZoneDeTexte'; + +export default function Motivation() { + return ( +
    + Votre 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 * + +
      +
    • + Avez-vous mis en place une stratégie d’inclusion numérique ? Si oui, quelles actions menez-vous dans ce cadre ? +
    • +
    • + Pourquoi souhaitez-vous intégrer le dispositif Conseiller numérique ? +
    • +
    • + Comment avez vous pensé le positionnement de votre Conseiller numérique ? +
    • +
    • + Avez vous réfléchi son positionnement géographique en complémentarité avec le maillage territorial existant ? +
    • +
    +
    + ); +} diff --git a/src/views/candidature-coordinateur/PageCandidatureCoordinateur.jsx b/src/views/candidature-coordinateur/PageCandidatureCoordinateur.jsx new file mode 100644 index 00000000..ba1cce08 --- /dev/null +++ b/src/views/candidature-coordinateur/PageCandidatureCoordinateur.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Header from '../../components/Header'; +import CandidatureStructure from './CandidatureCoordinateur'; + +export default function PageCandidatureStructure() { + return ( + <> +
    + + + ); +} diff --git a/src/views/candidature-coordinateur/SommaireCoordinateur.jsx b/src/views/candidature-coordinateur/SommaireCoordinateur.jsx new file mode 100644 index 00000000..7119147a --- /dev/null +++ b/src/views/candidature-coordinateur/SommaireCoordinateur.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Sommaire from '../../components/Sommaire'; + +export default function SommaireCoordinateur() { + const parties = [ + { + ancre: '#informations-de-structure', + libelle: 'Vos informations de structure' + }, + { + ancre: '#informations-de-contact', + libelle: 'Vos informations de contact' + }, + { + ancre: '#votre-besoin-en-coordinateur', + libelle: 'Votre besoin en coordinateur' + }, + { + ancre: '#votre-motivation', + libelle: 'Votre motivation' + } + ]; + + return ( + + ); +} diff --git a/src/views/candidature-structure/BesoinEnConseillerNumerique.jsx b/src/views/candidature-structure/BesoinEnConseillerNumerique.jsx new file mode 100644 index 00000000..ab2a0b19 --- /dev/null +++ b/src/views/candidature-structure/BesoinEnConseillerNumerique.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import BoutonRadio from '../../components/commun/BoutonRadio'; +import Datepicker from '../../components/commun/Datepicker'; +import Input from '../../components/commun/Input'; + +export default function BesoinEnConseillerNumerique() { + 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 ?* + +
    +

    Avez-vous déjà identifié un candidat pour le poste de conseiller numérique ?*

    +

    Si oui, merci d’inviter ce candidat à s’inscrire sur la plateforme Conseiller numérique.

    + + Oui + + + Non + +
    +

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

    + + Choisir une date + +
    + ); +} diff --git a/src/views/candidature-structure/CandidatureStructure.css b/src/views/candidature-structure/CandidatureStructure.css new file mode 100644 index 00000000..034ae7e6 --- /dev/null +++ b/src/views/candidature-structure/CandidatureStructure.css @@ -0,0 +1,76 @@ +/* API Adresse */ +.adresse-container { + position: relative; +} + +.liste-adresses { + background-color: var(--background-contrast-grey); + border: 1px solid var(--border-default-grey); + border-top: none; + position: absolute; + max-height: 33vh; + width: 100%; + z-index: 1000; +} + +.liste-adresses .spinner-div { + text-align: center; + margin: 0.5rem; + height: 1.5rem; +} + +.liste-adresses .adresses-trouvees { + max-height: 33vh; + overflow-y: auto; +} + +.liste-adresses .adresse { + text-align: left; + cursor: pointer; + padding: 0.75rem 1rem; + color: var(--text-title-grey); + transition: background-color 0.2s ease-in-out; +} + +.liste-adresses .adresse:hover { + background-color: var(--background-contrast-grey-hover); +} + +.liste-adresses .adresse:focus { + outline: 2px solid var(--focus-default); + outline-offset: -2px; +} + +.fr-input-wrap { + position: relative; +} + +.fr-input-spinner { + position: absolute; + right: 0.5rem; + top: 70%; + transform: translateY(-50%); +} + +.fr-input-spinner .fr-spinner { + width: 1.5rem; + height: 1.5rem; + border: 0.25rem solid var(--border-default-grey); + border-top-color: var(--text-action-high-blue-france); + border-radius: 50%; + animation: fr-spin 1s linear infinite; +} + +@keyframes fr-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +html { + scroll-behavior: smooth; +} diff --git a/src/views/candidature-structure/CandidatureStructure.jsx b/src/views/candidature-structure/CandidatureStructure.jsx new file mode 100644 index 00000000..9208e5c1 --- /dev/null +++ b/src/views/candidature-structure/CandidatureStructure.jsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from 'react'; +import SommaireStructure from './SommaireStructure'; +import InformationsDeContact from './InformationsDeContact'; +import InformationsDeStructure from './InformationsDeStructure'; +import BesoinEnConseillerNumerique from './BesoinEnConseillerNumerique'; +import Motivation from './Motivation'; +import Engagement from './Engagement'; +import Alert from '../../components/commun/Alert'; +import Captcha from '../../components/commun/Captcha'; +import { useScrollToSection } from '../../hooks/useScrollToSection'; +import { useNavigate } from 'react-router-dom'; +import { useApiAdmin } from '../candidature-conseiller/useApiAdmin'; + +import '@gouvfr/dsfr/dist/component/form/form.min.css'; +import '@gouvfr/dsfr/dist/component/input/input.min.css'; +import '@gouvfr/dsfr/dist/component/checkbox/checkbox.min.css'; +import '@gouvfr/dsfr/dist/component/radio/radio.min.css'; +import '@gouvfr/dsfr/dist/component/badge/badge.min.css'; +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'; + +export default function CandidatureStructure() { + const [geoLocation, setGeoLocation] = useState(null); + const [codeCommune, setCodeCommune] = useState(''); + const [validationError, setValidationError] = useState(''); + const navigate = useNavigate(); + const { buildStructureData, creerCandidatureStructure } = useApiAdmin(); + + useEffect(() => { + document.title = 'Conseiller numérique - Engager un conseiller numérique'; + }, []); + + useScrollToSection(); + + const validerLaCandidature = async event => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + const structureData = await buildStructureData(formData, geoLocation, codeCommune); + const resultatCreation = await creerCandidatureStructure(structureData); + if (resultatCreation?.status >= 400) { + const error = await resultatCreation.json(); + setValidationError(error.message); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else if (!resultatCreation.status) { + setValidationError(resultatCreation.message); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + navigate('/candidature-validee-structure'); + } + }; + + return ( +
    +
    +
    + +
    +
    +

    Je souhaite engager un conseiller numérique

    +

    Les champs avec * sont obligatoires.

    + {validationError && +
    + + {validationError} + +
    + } +
    + + + + + +
    + +
    + + +
    +
    +
    + ); +} diff --git a/src/views/candidature-structure/CandidatureStructure.test.jsx b/src/views/candidature-structure/CandidatureStructure.test.jsx new file mode 100644 index 00000000..de05cdde --- /dev/null +++ b/src/views/candidature-structure/CandidatureStructure.test.jsx @@ -0,0 +1,615 @@ +import { render, screen, within, waitFor, fireEvent, act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import CandidatureStructure from './CandidatureStructure'; +import { textMatcher, dateDujour } from '../../../test/test-utils'; +import * as ReactRouterDom from 'react-router-dom'; +import * as useApiAdmin from '../candidature-conseiller/useApiAdmin'; +import { useEntrepriseFinder } from './useEntrepriseFinder'; + +vi.mock('react-router-dom', () => ({ + useLocation: () => ({ hash: '' }), + useNavigate: vi.fn() +})); + +describe('candidature structure', () => { + it('quand j’affiche le formulaire alors le titre et le menu s’affichent', () => { + // WHEN + render(); + + // THEN + const titre = screen.getByRole('heading', { level: 1, name: textMatcher('Je souhaite engager un conseiller numérique') }); + expect(titre).toBeInTheDocument(); + + const champsObligatoires = screen.getByText(textMatcher('Les champs avec * sont obligatoires.'), { selector: 'p' }); + expect(champsObligatoires).toBeInTheDocument(); + + const navigation = screen.getByRole('navigation', { name: 'Sommaire' }); + const menu = within(navigation).getByRole('list'); + const menuItems = within(menu).getAllByRole('listitem'); + + const informationsDeStructure = within(menuItems[0]).getByRole('link', { name: 'Vos informations de structure' }); + expect(informationsDeStructure).toHaveAttribute('href', '#informations-de-structure'); + + const informationsDeContact = within(menuItems[1]).getByRole('link', { name: 'Vos informations de contact' }); + expect(informationsDeContact).toHaveAttribute('href', '#informations-de-contact'); + + const votreBesoinEnConseillerNumerique = within(menuItems[2]).getByRole('link', { name: 'Votre besoin en conseiller numérique' }); + expect(votreBesoinEnConseillerNumerique).toHaveAttribute('href', '#votre-besoin-en-conseiller-numerique'); + const votreMotivation = within(menuItems[3]).getByRole('link', { name: 'Votre motivation' }); + expect(votreMotivation).toHaveAttribute('href', '#votre-motivation'); + }); + + it('quand j’affiche le formulaire alors l’étape "Vos informations de structure" est affiché', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature structure' }); + const etapeInformationsDeStructure = within(formulaire).getByRole('group', { name: 'Vos informations de structure' }); + expect(etapeInformationsDeStructure).toHaveAttribute('id', 'informations-de-structure'); + + const siretOuRidet = within(etapeInformationsDeStructure).getByLabelText('SIRET / RIDET *'); + expect(siretOuRidet).toHaveAttribute('id', 'siret'); + expect(siretOuRidet).toBeRequired(); + + const denomination = within(etapeInformationsDeStructure).getByLabelText('Dénomination *'); + expect(denomination).toHaveAttribute('type', 'text'); + expect(denomination).toBeRequired(); + + const adresse = within(etapeInformationsDeStructure).getByLabelText('Adresse *'); + expect(adresse).toHaveAttribute('type', 'text'); + expect(adresse).toBeRequired(); + + const questionTypeDeStructure = within(etapeInformationsDeStructure).getByText(textMatcher('Votre structure est *'), { selector: 'p' }); + expect(etapeInformationsDeStructure).toHaveAttribute('id', 'informations-de-structure'); + expect(questionTypeDeStructure).toBeInTheDocument(); + + const uneCommune = screen.getByRole('radio', { name: 'Une commune' }); + expect(uneCommune).toBeRequired(); + expect(uneCommune).toHaveAttribute('name', 'type'); + + const unDepartement = screen.getByRole('radio', { name: 'Un département' }); + expect(unDepartement).toBeRequired(); + expect(unDepartement).toHaveAttribute('name', 'type'); + + const uneRegion = screen.getByRole('radio', { name: 'Une région' }); + expect(uneRegion).toBeRequired(); + expect(uneRegion).toHaveAttribute('name', 'type'); + + const unEtablissemntPublic = screen.getByRole('radio', { name: 'Un établissement public de coopération intercommunale' }); + expect(unEtablissemntPublic).toBeRequired(); + expect(unEtablissemntPublic).toHaveAttribute('name', 'type'); + + const uneCollectivite = screen.getByRole('radio', { name: 'Une collectivité à statut particulier' }); + expect(uneCollectivite).toBeRequired(); + expect(uneCollectivite).toHaveAttribute('name', 'type'); + + const unGIP = screen.getByRole('radio', { name: 'Un GIP' }); + expect(unGIP).toBeRequired(); + expect(unGIP).toHaveAttribute('name', 'type'); + + const uneStructurePrivee = screen.getByRole('radio', { name: 'Une structure privée (association, entreprise de l’ESS, fondations)' }); + expect(uneStructurePrivee).toBeRequired(); + expect(uneStructurePrivee).toHaveAttribute('name', 'type'); + }); + + it('quand j’affiche le formulaire alors l’étape "Vos informations de contact" est affiché', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature structure' }); + const etapeInformationsDeContact = within(formulaire).getByRole('group', { name: 'Vos informations de contact' }); + expect(etapeInformationsDeContact).toHaveAttribute('id', 'informations-de-contact'); + + const prenom = within(etapeInformationsDeContact).getByLabelText('Prénom *'); + expect(prenom).toHaveAttribute('type', 'text'); + expect(prenom).toBeRequired(); + + const nom = within(etapeInformationsDeContact).getByLabelText('Nom *'); + expect(nom).toHaveAttribute('type', 'text'); + expect(nom).toBeRequired(); + + const fonction = within(etapeInformationsDeContact).getByLabelText('Fonction *'); + expect(fonction).toHaveAttribute('type', 'text'); + expect(fonction).toBeRequired(); + + const email = within(etapeInformationsDeContact).getByLabelText('Adresse e-mail *'); + expect(email).toHaveAttribute('type', 'email'); + expect(email).toBeRequired(); + + const telephone = within(etapeInformationsDeContact).getByLabelText('Téléphone *'); + expect(telephone).toHaveAttribute('type', 'tel'); + expect(telephone).toHaveAttribute('pattern', '[+](33|590|596|594|262|269|687)[0-9]{9}'); + expect(telephone).toBeRequired(); + }); + + it('quand j’affiche le formulaire alors l’étape "Votre besoin en conseiller(s) numérique(s)" est affiché', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature structure' }); + const etapeBesoinConseillerNumerique = within(formulaire).getByRole('group', { name: 'Votre besoin en conseiller(s) numérique(s)' }); + expect(etapeBesoinConseillerNumerique).toHaveAttribute('id', 'votre-besoin-en-conseiller-numerique'); + + const combienConseillerNumerique = within(etapeBesoinConseillerNumerique).getByLabelText('Combien de conseillers numériques souhaitez-vous accueillir ?*'); + expect(combienConseillerNumerique).toHaveAttribute('type', 'number'); + expect(combienConseillerNumerique).toHaveAttribute('min', '1'); + expect(combienConseillerNumerique).toBeRequired(); + + const identificationCandidat = within(etapeBesoinConseillerNumerique).getByText( + textMatcher('Avez-vous déjà identifié un candidat pour le poste de conseiller numérique ?*'), + { selector: 'p' } + ); + expect(identificationCandidat).toBeInTheDocument(); + + const sousTitreIdentificationCandidat = + within(etapeBesoinConseillerNumerique).getByText( + textMatcher('Si oui, merci d’inviter ce candidat à s’inscrire sur la plateforme Conseiller numérique.'), + { selector: 'p' } + ); + expect(sousTitreIdentificationCandidat).toBeInTheDocument(); + + const oui = screen.getByRole('radio', { name: 'Oui' }); + expect(oui).toBeRequired(); + expect(oui).toHaveAttribute('name', 'aIdentifieCandidat'); + + const non = screen.getByRole('radio', { name: 'Non' }); + expect(non).toBeRequired(); + expect(non).toHaveAttribute('name', 'aIdentifieCandidat'); + + const dateDebutMission = within(etapeBesoinConseillerNumerique).getByText( + textMatcher('À partir de quand êtes vous prêt à accueillir votre conseiller numerique ?*'), + { selector: 'p' } + ); + expect(dateDebutMission).toBeInTheDocument(); + + const date = within(etapeBesoinConseillerNumerique).getByLabelText('Choisir une date'); + expect(date).toHaveAttribute('type', 'date'); + expect(date).toBeRequired(); + }); + + it('quand j’affiche le formulaire alors l’étape "Votre motivation" est affiché', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature structure' }); + const etapeMotivation = within(formulaire).getByRole('group', { name: 'Votre motivation' }); + expect(etapeMotivation).toHaveAttribute('id', 'votre-motivation'); + + const sousTitreVotreMotvation = + within(etapeMotivation).getByText( + '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é.', + { selector: 'p' } + ); + expect(sousTitreVotreMotvation).toBeInTheDocument(); + + const votreMessage = within(etapeMotivation).getByLabelText('Votre message *'); + expect(votreMessage).toHaveAttribute('id', 'motivation'); + expect(votreMessage).toBeRequired(); + }); + + it('quand j’affiche le formulaire alors l’encart des engagements est affiché', () => { + // WHEN + render(); + + // THEN + const formulaire = screen.getByRole('form', { name: 'Candidature structure' }); + const titreEngagement = within(formulaire).getByText(textMatcher('En tant que structure accueillante, vous vous engagez à'), { selector: 'p' }); + expect(titreEngagement).toBeInTheDocument(); + const engagements = screen.getByTestId('votre-engagement'); + + const listDetail = within(engagements).getAllByRole('listitem'); + within(listDetail[0]).getByText('Assurer que le conseiller réalise des activités de ' + + 'montée en compétences du public (ateliers numériques, initiations au numérique), gratuites.'); + within(listDetail[1]).getByText('Qu’il consacre une partie de son temps aux rencontres locales et ' + + 'nationales organisées pour la communauté et la formation continue, etc.'); + within(listDetail[2]).getByText('Qu’il revête une tenue vestimentaire dédiée fournie par l’Etat.'); + within(listDetail[3]).getByText('Tout mettre en oeuvre pour sélectionner le candidat dans un délai maximum d’un mois sur la plateforme.'); + within(listDetail[4]).getByText('Signer dans les 15 jours suivants un contrat avec ce candidat.'); + within(listDetail[5]).getByText('Laisser partir le conseiller numérique France Services en formation initiale ou continue.'); + within(listDetail[6]).getByText('Mettre à sa disposition les moyens et ' + + 'équipements pour réaliser sa mission (ordinateur, téléphone portable, voiture si nécessaire).'); + + const confirmationEngagement = screen.getByLabelText('Je confirme avoir lu et pris connaissance des conditions d’engagement.*'); + expect(confirmationEngagement).toBeInTheDocument(); + expect(confirmationEngagement).toBeRequired(); + + }); + + it('quand j’affiche le formulaire alors le bouton "Envoyer votre candidature" est affiché', () => { + // WHEN + render(); + + //THEN + const formulaire = screen.getByRole('form', { name: 'Candidature structure' }); + within(formulaire).getByRole('button', { name: 'Envoyer votre candidature' }); + }); + it('quand je renseigne un siret valide la dénomination et l’adresse de la structure sont affichées', async () => { + // GIVEN + vi.spyOn(global, 'fetch').mockImplementation(); + const mockApiResponse = { + nomStructure: 'AGENCE NATIONALE DE LA COHESION DES TERRITOIRES', + adresseStructure: '20 AVENUE DE SEGUR, 75007 PARIS', + isRidet: false, + }; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockApiResponse, + }); + + render(); + + // WHEN + const siretInput = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siretInput, { target: { value: '13002603200016' } }); + + // THEN + const denominationInput = screen.getByLabelText('Dénomination *'); + const adresseInput = screen.getByLabelText('Adresse *'); + await waitFor(() => { + expect(denominationInput).toHaveValue('AGENCE NATIONALE DE LA COHESION DES TERRITOIRES'); + }); + await waitFor(() => { + expect(adresseInput).toHaveValue('20 AVENUE DE SEGUR, 75007 PARIS'); + }); + }); + + it('quand je renseigne un ridet valide la dénomination de la structure est affichée', async () => { + // GIVEN + vi.spyOn(global, 'fetch').mockImplementation(); + const mockApiResponse = { + nomStructure: 'SELARL LUNA', + isRidet: true, + }; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockApiResponse, + }); + + render(); + + // WHEN + const ridetInput = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(ridetInput, { target: { value: '1071539' } }); + + // THEN + const denominationInput = screen.getByLabelText('Dénomination *'); + await waitFor(() => { + expect(denominationInput).toHaveValue('SELARL LUNA'); + }); + }); + + it('quand je renseigne ni un siret (14 chiffres) ni un ridet (6 ou 7 chiffres) alors les champs sont vidés', async () => { + // GIVEN + render(); + + // WHEN + const siretInput = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siretInput, { target: { value: '1300260320001' } }); + + // THEN + const denominationInput = screen.getByLabelText('Dénomination *'); + const adresseInput = screen.getByLabelText('Adresse *'); + await waitFor(() => { + expect(denominationInput).toHaveValue(''); + }); + await waitFor(() => { + expect(adresseInput).toHaveValue(''); + }); + }); + it('quand je fais une recherche par siret il y a un état de chargement', async () => { + // GIVEN + vi.spyOn(global, 'fetch').mockImplementation(); + let resolvePromise; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + global.fetch.mockReturnValueOnce(promise); + + render(); + + // WHEN + const siretInput = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siretInput, { target: { value: '13002603200016' } }); + + // THEN + await waitFor(() => { + const denominationInput = screen.getByLabelText('Dénomination *'); + expect(denominationInput).toHaveAttribute('aria-busy', 'true'); + }); + resolvePromise({ + ok: true, + json: async () => ({ + nomStructure: 'AGENCE NATIONALE DE LA COHESION DES TERRITOIRES', + adresseStructure: '20 AVENUE DE SEGUR, 75007 PARIS', + isRidet: false + }) + }); + await waitFor(() => { + const denominationInput = screen.getByLabelText('Dénomination *'); + expect(denominationInput).toHaveAttribute('aria-busy', 'false'); + }); + await waitFor(() => { + const adresseInput = screen.getByLabelText('Adresse *'); + expect(adresseInput).toHaveValue('20 AVENUE DE SEGUR, 75007 PARIS'); + }); + }); + + it('quand je remplis le formulaire, que je l’envoie et que le serveur me renvoie une erreur, alors elle s’affiche sur la page', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 400, json: async () => Promise.resolve({ message: 'Cette adresse mail est déjà utilisée' }) })) + ); + + render(); + const siret = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siret, { target: { value: '1234567890123' } }); + const denomination = screen.getByLabelText('Dénomination *'); + fireEvent.change(denomination, { target: { value: 'Entreprise' } }); + const adresse = screen.getByLabelText('Adresse *'); + fireEvent.change(adresse, { target: { value: '75007 Paris' } }); + const typeStructure = screen.getByRole('radio', { name: 'Une commune' }); + fireEvent.click(typeStructure); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const fonction = screen.getByLabelText('Fonction *'); + fireEvent.change(fonction, { target: { value: 'Test' } }); + const email = screen.getByLabelText('Adresse e-mail *'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const telephone = screen.getByLabelText('Téléphone *'); + fireEvent.change(telephone, { target: { value: '+33123456789' } }); + const nombre = screen.getByLabelText('Combien de conseillers numériques souhaitez-vous accueillir ?*'); + fireEvent.change(nombre, { target: { value: 1 } }); + const identificationCandidat = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(identificationCandidat); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + const confirmation = screen.getByRole('checkbox', { name: 'Je confirme avoir lu et pris connaissance des conditions d’engagement. *' }); + fireEvent.click(confirmation); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + // THEN + const titreErreurValidation = screen.getByRole('heading', { level: 3, name: 'Erreur de validation' }); + expect(titreErreurValidation).toBeInTheDocument(); + const contenuErreurValidation = screen.getByText('Cette adresse mail est déjà utilisée', { selector: 'p' }); + expect(contenuErreurValidation).toBeInTheDocument(); + vi.useRealTimers(); + }); + + it('quand je remplis le formulaire avec toutes les informations valides, alors je suis redirigé vers la page de candidature validée', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 200, json: async () => Promise.resolve({}) })) + ); + + const mockNavigate = vi.fn().mockReturnValue(() => { }); + vi.spyOn(ReactRouterDom, 'useNavigate').mockReturnValue(mockNavigate); + + render(); + const siret = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siret, { target: { value: '1234567890123' } }); + const denomination = screen.getByLabelText('Dénomination *'); + fireEvent.change(denomination, { target: { value: 'Entreprise' } }); + const adresse = screen.getByLabelText('Adresse *'); + fireEvent.change(adresse, { target: { value: '75007 Paris' } }); + const typeStructure = screen.getByRole('radio', { name: 'Une commune' }); + fireEvent.click(typeStructure); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const fonction = screen.getByLabelText('Fonction *'); + fireEvent.change(fonction, { target: { value: 'Test' } }); + const email = screen.getByLabelText('Adresse e-mail *'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const telephone = screen.getByLabelText('Téléphone *'); + fireEvent.change(telephone, { target: { value: '+33123456789' } }); + const nombre = screen.getByLabelText('Combien de conseillers numériques souhaitez-vous accueillir ?*'); + fireEvent.change(nombre, { target: { value: 1 } }); + const identificationCandidat = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(identificationCandidat); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + const confirmation = screen.getByRole('checkbox', { name: 'Je confirme avoir lu et pris connaissance des conditions d’engagement. *' }); + fireEvent.click(confirmation); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + // THEN + expect(mockNavigate).toHaveBeenCalledWith('/candidature-validee-structure'); + + vi.useRealTimers(); + }); + + it('quand je valide le formulaire alors j’envoie toute les données nescessaires', async () => { + // GIVEN + const formData = [ + [ + 'siret', + '13002603200016' + ], + [ + 'denomination', + 'AGENCE NATIONALE DE LA COHESION DES TERRITOIRES' + ], + [ + 'adresse', + '20 AVENUE DE SEGUR, 75007 PARIS' + ], + [ + 'type', + 'COMMUNE' + ], + [ + 'prenom', + 'Jean' + ], + [ + 'nom', + 'Dupont' + ], + [ + 'fonction', + 'Test' + ], + [ + 'email', + 'jean.dupont@example.com' + ], + [ + 'telephone', + '+33123456789' + ], + [ + 'nombreConseillersSouhaites', + '1' + ], + [ + 'aIdentifieCandidat', + 'oui' + ], + [ + 'dateDebutMission', + '2024-12-12' + ], + [ + 'motivation', + 'je suis motivé !' + ], + [ + 'confirmationEngagement', + 'on' + ], + [ + 'g-recaptcha-response', + '1' + ], + [ + 'h-captcha-response', + '1' + ] + ]; + + const { buildStructureData } = renderHook(() => useApiAdmin.useApiAdmin()).result.current; + const { getGeoLocationFromAddress } = renderHook(() => useEntrepriseFinder()).result.current; + + // //WHEN + const geoLocation = await getGeoLocationFromAddress('20 AVENUE DE SEGUR, 75007 PARIS'); + const result = await buildStructureData(formData, geoLocation, '75107'); + + // THEN + expect(result).toBe(JSON.stringify({ + 'siret': '13002603200016', + 'type': 'COMMUNE', + 'nombreConseillersSouhaites': '1', + 'aIdentifieCandidat': true, + 'dateDebutMission': '2024-12-12', + 'motivation': 'je suis motivé !', + 'confirmationEngagement': true, + 'h-captcha-response': '1', + 'location': { 'type': 'Point', 'coordinates': [2.3115, 48.8548] }, + 'contact': { + 'prenom': 'Jean', + 'nom': 'Dupont', + 'fonction': 'Test', + 'email': 'jean.dupont@example.com', + 'telephone': '+33123456789' + }, + 'nom': 'AGENCE NATIONALE DE LA COHESION DES TERRITOIRES', + 'nomCommune': 'Paris 7e Arrondissement', + 'codePostal': '75007', + 'codeCommune': '75107', + 'codeDepartement': '75', + 'codeRegion': '11', + 'codeCom': null, + })); + + vi.useRealTimers(); + }); + + it('quand je remplis le formulaire et qu’une erreur se produit alors un message d’erreur s’affiche', async () => { + // GIVEN + vi.useFakeTimers(); + vi.setSystemTime(new Date(2023, 11, 12, 13)); + + vi.spyOn(useApiAdmin, 'useApiAdmin').mockImplementation(() => ({ + creerCandidatureStructure: vi.fn().mockReturnValue({ message: 'Failed to fetch' }), + buildStructureData: vi.fn(), + })); + + render(); + const siret = screen.getByLabelText('SIRET / RIDET *'); + fireEvent.change(siret, { target: { value: '1234567890123' } }); + const denomination = screen.getByLabelText('Dénomination *'); + fireEvent.change(denomination, { target: { value: 'Entreprise' } }); + const adresse = screen.getByLabelText('Adresse *'); + fireEvent.change(adresse, { target: { value: '75007 Paris' } }); + const typeStructure = screen.getByRole('radio', { name: 'Une commune' }); + fireEvent.click(typeStructure); + const prenom = screen.getByLabelText('Prénom *'); + fireEvent.change(prenom, { target: { value: 'Jean' } }); + const nom = screen.getByLabelText('Nom *'); + fireEvent.change(nom, { target: { value: 'Dupont' } }); + const fonction = screen.getByLabelText('Fonction *'); + fireEvent.change(fonction, { target: { value: 'Test' } }); + const email = screen.getByLabelText('Adresse e-mail *'); + fireEvent.change(email, { target: { value: 'jean.dupont@example.com' } }); + const telephone = screen.getByLabelText('Téléphone *'); + fireEvent.change(telephone, { target: { value: '+33123456789' } }); + const nombre = screen.getByLabelText('Combien de conseillers numériques souhaitez-vous accueillir ?*'); + fireEvent.change(nombre, { target: { value: 1 } }); + const identificationCandidat = screen.getByRole('radio', { name: 'Oui' }); + fireEvent.click(identificationCandidat); + const date = screen.getByLabelText('Choisir une date'); + fireEvent.change(date, { target: { value: dateDujour() } }); + const descriptionMotivation = screen.getByLabelText('Votre message *'); + fireEvent.change(descriptionMotivation, { target: { value: 'je suis motivé !' } }); + const confirmation = screen.getByRole('checkbox', { name: 'Je confirme avoir lu et pris connaissance des conditions d’engagement. *' }); + fireEvent.click(confirmation); + + // WHEN + const envoyer = screen.getByRole('button', { name: 'Envoyer votre candidature' }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + + + // THEN + const contenuErreurValidation = screen.getByText('Failed to fetch', { selector: 'p' }); + expect(contenuErreurValidation).toBeInTheDocument(); + + vi.useRealTimers(); + }); +}); + diff --git a/src/views/candidature-structure/CompanyFinder.jsx b/src/views/candidature-structure/CompanyFinder.jsx new file mode 100644 index 00000000..8e2c7d91 --- /dev/null +++ b/src/views/candidature-structure/CompanyFinder.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import Input from '../../components/commun/Input'; +import { debounce } from '../candidature-conseiller/debounce'; +import PropTypes from 'prop-types'; + +export default function CompanyFinder({ onSearch }) { + const handleSearch = debounce(value => { + onSearch(value); + }); + + return ( + handleSearch(event.target.value)} + > + SIRET / RIDET * + + ); +} + +CompanyFinder.propTypes = { + onSearch: PropTypes.func +}; diff --git a/src/views/candidature-structure/Engagement.jsx b/src/views/candidature-structure/Engagement.jsx new file mode 100644 index 00000000..28fb886a --- /dev/null +++ b/src/views/candidature-structure/Engagement.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Notice from '../../components/commun/Notice'; +import Checkbox from '../../components/commun/Checkbox'; + +export default function Engagement() { + return ( + +

    En tant que structure accueillante, vous vous engagez à

    +
      +
    • Assurer que le conseiller réalise des activités de montée en compétences du public (ateliers numériques, initiations au numérique), gratuites.
    • +
    • Qu’il consacre une partie de son temps aux rencontres locales et nationales organisées pour la communauté et la formation continue, etc.
    • +
    • Qu’il revête une tenue vestimentaire dédiée fournie par l’Etat.
    • +
    • Tout mettre en oeuvre pour sélectionner le candidat dans un délai maximum d’un mois sur la plateforme.
    • +
    • Signer dans les 15 jours suivants un contrat avec ce candidat.
    • +
    • Laisser partir le conseiller numérique France Services en formation initiale ou continue.
    • +
    • Mettre à sa disposition les moyens et équipements pour réaliser sa mission (ordinateur, téléphone portable, voiture si nécessaire).
    • +
    + + Je confirme avoir lu et pris connaissance des conditions d’engagement.* + +
    + ); +} diff --git a/src/views/candidature-structure/InformationsDeContact.jsx b/src/views/candidature-structure/InformationsDeContact.jsx new file mode 100644 index 00000000..b5d7a283 --- /dev/null +++ b/src/views/candidature-structure/InformationsDeContact.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import Input from '../../components/commun/Input'; + +export default function InformationsDeContact() { + return ( +
    + Vos informations de contact +
    + + Prénom * + + + Nom * + + + Fonction * + + + Adresse e-mail * + + + Téléphone * + +
    + ); +} diff --git a/src/views/candidature-structure/InformationsDeStructure.jsx b/src/views/candidature-structure/InformationsDeStructure.jsx new file mode 100644 index 00000000..3ace056e --- /dev/null +++ b/src/views/candidature-structure/InformationsDeStructure.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import Input from '../../components/commun/Input'; +import CompanyFinder from './CompanyFinder'; +import BoutonRadio from '../../components/commun/BoutonRadio'; +import { useEntrepriseFinder } from './useEntrepriseFinder'; +import PropTypes from 'prop-types'; +import './CandidatureStructure.css'; + +const TAILLE_SIRET = 14; +const TAILLE_RIDET = [6, 7]; +const TAILLES_POSSIBLES = [...TAILLE_RIDET, TAILLE_SIRET]; + +export default function InformationsDeStructure({ setGeoLocation, setCodeCommune }) { + const { + entreprise, + search, + getAddressSuggestions, + addressSuggestions, + loading, + addressLoading, + clearEntrepriseData, + denomination, + setDenomination, + adresse, + setAdresse, + } = useEntrepriseFinder(setGeoLocation, setCodeCommune); + + const handleSearch = value => { + const numericValue = value.replace(/\D/g, ''); + if (TAILLES_POSSIBLES.includes(numericValue.length)) { + search(numericValue); + } else { + clearEntrepriseData(); + } + }; + + const handleAdresseChange = event => { + setAdresse(event.target.value); + if (entreprise?.isRidet) { + getAddressSuggestions(event.target.value); + } + }; + + const handleSuggestionClick = suggestion => { + setAdresse(suggestion.label); + setCodeCommune(suggestion.codeCommune); + setGeoLocation(suggestion.geometry); + getAddressSuggestions(''); + }; + + return ( +
    + Vos informations de structure +
    + + setDenomination(event.target.value)} + > + Dénomination * + +
    + + Adresse * + +
    +
    + {addressSuggestions.map((suggestion, index) => ( +
    handleSuggestionClick(suggestion)}> + {suggestion.label} +
    + ))} +
    +
    +
    +

    + Votre structure est * +

    +
    + + Une commune + + + Un département + + + Une région + + + Un établissement public de coopération intercommunale + + + Une collectivité à statut particulier + + + Un GIP + + + Une structure privée (association, entreprise de l’ESS, fondations) + +
    +
    + ); +} + +InformationsDeStructure.propTypes = { + setGeoLocation: PropTypes.func.isRequired, + setCodeCommune: PropTypes.func.isRequired, + geoLocation: PropTypes.object, +}; diff --git a/src/views/candidature-structure/Motivation.jsx b/src/views/candidature-structure/Motivation.jsx new file mode 100644 index 00000000..cd0507a8 --- /dev/null +++ b/src/views/candidature-structure/Motivation.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ZoneDeTexte from '../../components/commun/ZoneDeTexte'; + +export default function Motivation() { + return ( +
    + Votre 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 * + +
    + ); +} diff --git a/src/views/candidature-structure/PageCandidatureStructure.jsx b/src/views/candidature-structure/PageCandidatureStructure.jsx new file mode 100644 index 00000000..a5ab0785 --- /dev/null +++ b/src/views/candidature-structure/PageCandidatureStructure.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Header from '../../components/Header'; +import CandidatureStructure from './CandidatureStructure'; + +export default function PageCandidatureStructure() { + return ( + <> +
    + + + ); +} diff --git a/src/views/candidature-structure/SommaireStructure.jsx b/src/views/candidature-structure/SommaireStructure.jsx new file mode 100644 index 00000000..510fa214 --- /dev/null +++ b/src/views/candidature-structure/SommaireStructure.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Sommaire from '../../components/Sommaire'; + +export default function SommaireStructure() { + const parties = [ + { + ancre: '#informations-de-structure', + libelle: 'Vos informations de structure' + }, + { + ancre: '#informations-de-contact', + libelle: 'Vos informations de contact' + }, + { + ancre: '#votre-besoin-en-conseiller-numerique', + libelle: 'Votre besoin en conseiller numérique' + }, + { + ancre: '#votre-motivation', + libelle: 'Votre motivation' + } + ]; + + return ( + + ); +} diff --git a/src/views/candidature-structure/useEntrepriseFinder.js b/src/views/candidature-structure/useEntrepriseFinder.js new file mode 100644 index 00000000..e605153d --- /dev/null +++ b/src/views/candidature-structure/useEntrepriseFinder.js @@ -0,0 +1,119 @@ +import { useState } from 'react'; + +const TAILLE_SIRET = 14; +const TAILLE_RIDET = [6, 7]; +const TAILLES_POSSIBLES = [...TAILLE_RIDET, TAILLE_SIRET]; + +export const useEntrepriseFinder = (setGeoLocation, setCodeCommune) => { + const [entreprise, setEntreprise] = useState(null); + const [error, setError] = useState(null); + const [addressSuggestions, setAddressSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + const [addressLoading, setAddressLoading] = useState(false); + const [denomination, setDenomination] = useState(''); + const [adresse, setAdresse] = useState(''); + + const isValidSiretOrRidet = value => { + const numericValue = value.replace(/\D/g, ''); + return TAILLES_POSSIBLES.includes(numericValue.length); + }; + + const clearEntrepriseData = () => { + setEntreprise(null); + setError(null); + setGeoLocation(null); + setCodeCommune(''); + setDenomination(''); + setAdresse(''); + }; + + const getGeoLocationFromAddress = async address => { + try { + const urlAPI = `https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(address)}`; + const response = await fetch(urlAPI); + const result = await response.json(); + if (result.features && result.features.length > 0) { + setCodeCommune(result.features[0].properties.citycode); + return result.features[0].geometry; + } + } catch (error) { + setError('Impossible de trouver la localisation de l\'adresse.'); + } + return null; + }; + + const search = async siretOrRidet => { + clearEntrepriseData(); + setEntreprise(null); + setError(null); + setAddressSuggestions([]); + if (!isValidSiretOrRidet(siretOrRidet)) { + setError('Veuillez entrer un RIDET (6 ou 7 chiffres) ou un SIRET (14 chiffres) valide.'); + return; + } + setLoading(true); + try { + const baseUrl = import.meta.env.VITE_APP_API_PILOTAGE_URL; + const response = await fetch(`${baseUrl}/structure/verify-siret-or-ridet/${siretOrRidet}`); + if (!response.ok) { + throw new Error(`Error fetching data: ${response.statusText}`); + } + const result = await response.json(); + setEntreprise(result); + setDenomination(result.nomStructure || ''); + if (siretOrRidet.length === TAILLE_SIRET && result.adresseStructure) { + setAdresse(result.adresseStructure); + const geoLocation = await getGeoLocationFromAddress(result.adresseStructure); + if (geoLocation) { + setGeoLocation(geoLocation); + } + } + setError(null); + } catch (err) { + setError(err.message); + setEntreprise(null); + } finally { + setLoading(false); + } + }; + + const getAddressSuggestions = async adressePostale => { + if (!adressePostale) { + setAddressSuggestions([]); + return; + } + try { + setAddressLoading(true); + const urlAPI = `https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(adressePostale)}`; + const response = await fetch(urlAPI); + const result = await response.json(); + const suggestions = result.features.map(feature => ({ + label: `${feature.properties.postcode} ${feature.properties.city}`, + codeCommune: feature.properties.citycode, + geometry: feature.geometry, + })); + setAddressSuggestions(suggestions); + } catch (error) { + setAddressSuggestions([]); + } finally { + setAddressLoading(false); + } + }; + + return { + getGeoLocationFromAddress, + search, + entreprise, + error, + getAddressSuggestions, + addressSuggestions, + loading, + addressLoading, + clearEntrepriseData, + setDenomination, + setAdresse, + denomination, + adresse, + setCodeCommune, + }; +}; diff --git a/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.css b/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.css new file mode 100644 index 00000000..cec63313 --- /dev/null +++ b/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.css @@ -0,0 +1,7 @@ +.cv-contenu { + text-align: center; +} + +.cv-titre { + color: var(--blue-france-sun-113-625); +} diff --git a/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.jsx b/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.jsx new file mode 100644 index 00000000..f3de3a31 --- /dev/null +++ b/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.jsx @@ -0,0 +1,24 @@ +import React, { useEffect } from 'react'; + +import './CandidatureValideeConseiller.css'; + +export default function CandidatureValideeConseiller() { + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + document.title = 'Conseiller numérique - Candidature validée'; + }, []); + + return ( +
    +
    👏
    +

    Merci, votre demande a été envoyée.

    +

    + Pour confirmer votre inscription et pouvoir recevoir des propositions de structure consultez le mail qui vient de vous être envoyé.
    + Si toutefois vous ne receviez pas dans les prochaines minutes un mail de confirmation de votre inscription, pensez à vérifier vos spams. +

    + + Retour à la page d’accueil + +
    + ); +} diff --git a/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.test.jsx b/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.test.jsx new file mode 100644 index 00000000..a677c80e --- /dev/null +++ b/src/views/candidature-validee-conseiller/CandidatureValideeConseiller.test.jsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import CandidatureValideeConseiller from './CandidatureValideeConseiller'; +import { textMatcher } from '../../../test/test-utils'; + +describe('candidature validée', () => { + it('quand j’affiche la page candidature validée alors le titre et les informations de la page s’affichent', () => { + // WHEN + render(); + + // THEN + const emoji = screen.getByText('👏'); + expect(emoji).toBeInTheDocument(); + + const titre = screen.getByRole('heading', { level: 1, name: 'Merci, votre demande a été envoyée.' }); + expect(titre).toBeInTheDocument(); + + const confirmation = screen.getByText( + textMatcher('Pour confirmer votre inscription et pouvoir recevoir des propositions de structure consultez le mail qui vient de vous être envoyé.' + + 'Si toutefois vous ne receviez pas dans les prochaines minutes un mail de confirmation de votre inscription, pensez à vérifier vos spams.'), + { selector: 'p' } + ); + expect(confirmation).toBeInTheDocument(); + + const retourAccueil = screen.getByRole('link', { name: 'Retour à la page d’accueil' }); + expect(retourAccueil).toBeInTheDocument(); + }); +}); diff --git a/src/views/candidature-validee-conseiller/PageCandidatureValideeConseiller.jsx b/src/views/candidature-validee-conseiller/PageCandidatureValideeConseiller.jsx new file mode 100644 index 00000000..6638853a --- /dev/null +++ b/src/views/candidature-validee-conseiller/PageCandidatureValideeConseiller.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Header from '../../components/Header'; +import CandidatureValideeConseiller from './CandidatureValideeConseiller'; + +export default function PageCandidatureValideeConseiller() { + return ( + <> +
    + + + ); +} diff --git a/src/views/candidature-validee-structure/CandidatureValideeStructure.css b/src/views/candidature-validee-structure/CandidatureValideeStructure.css new file mode 100644 index 00000000..cec63313 --- /dev/null +++ b/src/views/candidature-validee-structure/CandidatureValideeStructure.css @@ -0,0 +1,7 @@ +.cv-contenu { + text-align: center; +} + +.cv-titre { + color: var(--blue-france-sun-113-625); +} diff --git a/src/views/candidature-validee-structure/CandidatureValideeStructure.jsx b/src/views/candidature-validee-structure/CandidatureValideeStructure.jsx new file mode 100644 index 00000000..6a11c27f --- /dev/null +++ b/src/views/candidature-validee-structure/CandidatureValideeStructure.jsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react'; + +import './CandidatureValideeStructure.css'; + +export default function CandidatureValideeStructure() { + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + document.title = 'Conseiller numérique - Candidature validée'; + }, []); + + return ( +
    +
    👏
    +

    Merci, votre demande a été envoyée.

    +

    + Pour confirmer votre inscription et recevoir des propositions de candidats, veuillez{' '} + consulter l’email qui vient de vous être envoyé. Si vous ne recevez pas cet email dans les prochaines minutes,{' '} + pensez à vérifier votre dossier de spams. +

    + + Retour à la page d’accueil + +
    + ); +} diff --git a/src/views/candidature-validee-structure/CandidatureValideeStructure.test.jsx b/src/views/candidature-validee-structure/CandidatureValideeStructure.test.jsx new file mode 100644 index 00000000..0310581a --- /dev/null +++ b/src/views/candidature-validee-structure/CandidatureValideeStructure.test.jsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import CandidatureValideeStructure from './CandidatureValideeStructure'; +import { textMatcher } from '../../../test/test-utils'; + +describe('candidature validée', () => { + it('quand j’affiche la page candidature validée alors le titre et les informations de la page s’affichent', () => { + // WHEN + render(); + + // THEN + const emoji = screen.getByText('👏'); + expect(emoji).toBeInTheDocument(); + + const titre = screen.getByRole('heading', { level: 1, name: 'Merci, votre demande a été envoyée.' }); + expect(titre).toBeInTheDocument(); + + const confirmation = screen.getByText( + textMatcher('Pour confirmer votre inscription et recevoir des propositions de candidats, veuillez consulter ' + + 'l’email qui vient de vous être envoyé. Si vous ne recevez pas cet email dans les prochaines minutes, pensez à ' + + 'vérifier votre dossier de spams.'), + { selector: 'p' } + ); + expect(confirmation).toBeInTheDocument(); + + const retourAccueil = screen.getByRole('link', { name: 'Retour à la page d’accueil' }); + expect(retourAccueil).toBeInTheDocument(); + }); +}); diff --git a/src/views/candidature-validee-structure/PageCandidatureValideeStructure.jsx b/src/views/candidature-validee-structure/PageCandidatureValideeStructure.jsx new file mode 100644 index 00000000..cc586609 --- /dev/null +++ b/src/views/candidature-validee-structure/PageCandidatureValideeStructure.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Header from '../../components/Header'; +import CandidatureValideeStructure from './CandidatureValideeStructure'; + +export default function PageCandidatureValideeStructure() { + return ( + <> +
    + + + ); +} diff --git a/src/views/confirmation-email-candidature-conseiller/ConfirmationEmailCandidatureConseiller.jsx b/src/views/confirmation-email-candidature-conseiller/ConfirmationEmailCandidatureConseiller.jsx new file mode 100644 index 00000000..3de50183 --- /dev/null +++ b/src/views/confirmation-email-candidature-conseiller/ConfirmationEmailCandidatureConseiller.jsx @@ -0,0 +1,42 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useApiConfirmationEmailCandidatureConseiller } from './useApiConfirmationEmailCandidatureConseiller'; + +import '@gouvfr/dsfr/dist/component/alert/alert.min.css'; + +export default function ConfirmationEmailCandidatureConseiller() { + const [reponseStatusConfirmation, setReponseStatusConfirmation] = useState(null); + const { actionConfirmationEmailCandidatureConseiller } = useApiConfirmationEmailCandidatureConseiller(); + const { token } = useParams(); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + const ClickBoutonConfirmer = async () => { + const response = await actionConfirmationEmailCandidatureConseiller(token); + setReponseStatusConfirmation(response.status); + }; + return ( +
    +

    Confirmation de l’enregistrement de votre candidature

    + {reponseStatusConfirmation === 200 &&
    +

    Votre email a été confirmé et votre inscription est maintenant active. + Vous serez contacté par mail ou par téléphone si une structure est intéressée par votre profil.

    +
    } + {reponseStatusConfirmation === 403 &&
    +

    Le lien de validation de votre adresse électronique est invalide.

    +
    } + {reponseStatusConfirmation >= 400 && reponseStatusConfirmation !== 403 &&
    +

    Une erreur s’est produite veuillez réessayer plus tard.

    +
    } + {!reponseStatusConfirmation && <> +

    + Appuyez sur le bouton pour confirmer votre email +

    + + } +
    + ); +} diff --git a/src/views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller.jsx b/src/views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller.jsx new file mode 100644 index 00000000..da40ebe6 --- /dev/null +++ b/src/views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Header from '../../components/Header'; +import ConfirmationEmailCandidatureConseiller from './ConfirmationEmailCandidatureConseiller'; + +export default function PageConfirmationEmailCandidatureConseiller() { + return ( + <> +
    + + + ); +} diff --git a/src/views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller.test.jsx b/src/views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller.test.jsx new file mode 100644 index 00000000..bacde01e --- /dev/null +++ b/src/views/confirmation-email-candidature-conseiller/PageConfirmationEmailCandidatureConseiller.test.jsx @@ -0,0 +1,125 @@ +import { render, act, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import ConfirmationEmailCandidature from './ConfirmationEmailCandidatureConseiller'; + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ token: '1' }), +})); + +describe('confirmation Email', () => { + it('quand j’affiche la page de confirmation de l’email validée alors le titre et les informations de la page s’affichent', () => { + // WHEN + render(); + + // THEN + const titre = screen.getByRole('heading', { level: 1, name: 'Confirmation de l’enregistrement de votre candidature' }); + expect(titre).toBeInTheDocument(); + + const indication = screen.getByText('Appuyez sur le bouton pour confirmer votre email', + { selector: 'p' } + ); + expect(indication).toBeInTheDocument(); + + const envoyer = screen.getByRole('button', { name: 'Confirmer' }); + expect(envoyer).toBeInTheDocument(); + }); + + it('quand l’utilisateur clique sur le bouton et que le lien est valide alors un message de succès s’affiche', async () => { + // WHEN + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 200, json: async () => Promise.resolve({}) })) + ); + render(); + + const envoyer = screen.getByRole('button', { name: 'Confirmer' }); + expect(envoyer).toBeInTheDocument(); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + // THEN + expect(envoyer).not.toBeInTheDocument(); + const titre = screen.getByRole('heading', { level: 1, name: 'Confirmation de l’enregistrement de votre candidature' }); + expect(titre).toBeInTheDocument(); + + const messageSucess = screen.getByText('Votre email a été confirmé et votre inscription est maintenant active.' + + ' Vous serez contacté par mail ou par téléphone si une structure est intéressée par votre profil.', + { selector: 'p' } + ); + expect(messageSucess).toBeInTheDocument(); + + const messageError403 = screen.queryByText('Le lien de validation de votre adresse électronique est invalide.', { selector: 'p' }); + expect(messageError403).not.toBeInTheDocument(); + + const messageErreurGenerale = screen.queryByText('Une erreur s’est produite veuillez réessayer plus tard.', { selector: 'p' }); + expect(messageErreurGenerale).not.toBeInTheDocument(); + }); + + it('quand l’utilisateur clique sur le bouton et que le lien est invalide alors un message d’erreur s’affiche', async () => { + + // GIVEN + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 403, json: async () => Promise.resolve({}) })) + ); + // WHEN + render(); + + // THEN + const envoyer = screen.getByRole('button', { name: 'Confirmer' }); + expect(envoyer).toBeInTheDocument(); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + expect(envoyer).not.toBeInTheDocument(); + // THEN + const titre = screen.getByRole('heading', { level: 1, name: 'Confirmation de l’enregistrement de votre candidature' }); + expect(titre).toBeInTheDocument(); + + const messageError = screen.getByText('Le lien de validation de votre adresse électronique est invalide.', + { selector: 'p' } + ); + expect(messageError).toBeInTheDocument(); + + const messageSuccess = screen.queryByText('Votre email a été confirmé et votre inscription est maintenant active.' + + ' Vous serez contacté par mail ou par téléphone si une structure est intéressée par votre profil.', { selector: 'p' }); + expect(messageSuccess).not.toBeInTheDocument(); + const messageErreurGenerale = screen.queryByText('Une erreur s’est produite veuillez réessayer plus tard.', { selector: 'p' }); + expect(messageErreurGenerale).not.toBeInTheDocument(); + }); + + + it('quand l’utilisateur clique sur le bouton et qu’une erreur innatendue se produit alors un message d’erreur s’affiche', async () => { + // GIVEN + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 500, json: async () => Promise.resolve({}) })) + ); + // WHEN + render(); + + // THEN + const envoyer = screen.getByRole('button', { name: 'Confirmer' }); + expect(envoyer).toBeInTheDocument(); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + expect(envoyer).not.toBeInTheDocument(); + // THEN + const titre = screen.getByRole('heading', { level: 1, name: 'Confirmation de l’enregistrement de votre candidature' }); + expect(titre).toBeInTheDocument(); + + const messageError = screen.getByText('Une erreur s’est produite veuillez réessayer plus tard.', + { selector: 'p' } + ); + expect(messageError).toBeInTheDocument(); + + const messageError403 = screen.queryByText('Le lien de validation de votre adresse électronique est invalide.', { selector: 'p' }); + expect(messageError403).not.toBeInTheDocument(); + + const messageSuccess = screen.queryByText('Votre email a été confirmé et votre inscription est maintenant active.' + + ' Vous serez contacté par mail ou par téléphone si une structure est intéressée par votre profil.', { selector: 'p' }); + expect(messageSuccess).not.toBeInTheDocument(); + + }); +}); diff --git a/src/views/confirmation-email-candidature-conseiller/useApiConfirmationEmailCandidatureConseiller.js b/src/views/confirmation-email-candidature-conseiller/useApiConfirmationEmailCandidatureConseiller.js new file mode 100644 index 00000000..9f45851f --- /dev/null +++ b/src/views/confirmation-email-candidature-conseiller/useApiConfirmationEmailCandidatureConseiller.js @@ -0,0 +1,19 @@ + +export const useApiConfirmationEmailCandidatureConseiller = () => { + + + const actionConfirmationEmailCandidatureConseiller = async token => { + const baseUrl = import.meta.env.VITE_APP_API_PILOTAGE_URL; + const requestOptions = { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + }; + try { + return await fetch(`${baseUrl}/confirmation-email-inscription-conseiller/${token}`, requestOptions); + } catch (error) { + return error; + } + }; + + return { actionConfirmationEmailCandidatureConseiller }; +}; diff --git a/src/views/confirmation-email-candidature-structure/ConfirmationEmailCandidatureStructure.jsx b/src/views/confirmation-email-candidature-structure/ConfirmationEmailCandidatureStructure.jsx new file mode 100644 index 00000000..f24e1f4c --- /dev/null +++ b/src/views/confirmation-email-candidature-structure/ConfirmationEmailCandidatureStructure.jsx @@ -0,0 +1,42 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useApiConfirmationEmailCandidatureStructure } from './useApiConfirmationEmailCandidatureStructure'; + +import '@gouvfr/dsfr/dist/component/alert/alert.min.css'; + +export default function ConfirmationEmailCandidatureStructure() { + const [reponseStatusConfirmation, setReponseStatusConfirmation] = useState(null); + const { actionConfirmationEmailCandidatureStructure } = useApiConfirmationEmailCandidatureStructure(); + const { token } = useParams(); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + const confirmerEmailCandidatureStructure = async () => { + const response = await actionConfirmationEmailCandidatureStructure(token); + setReponseStatusConfirmation(response.status); + }; + return ( +
    +

    Confirmation de l’enregistrement de votre candidature

    + {reponseStatusConfirmation === 200 &&
    +

    Votre email a été confirmé et votre inscription est maintenant active. + Vous recevrez un mail d’activation de votre espace structure lorsque votre candidature aura été validée.

    +
    } + {reponseStatusConfirmation === 403 &&
    +

    Le lien de validation de votre adresse électronique est invalide.

    +
    } + {reponseStatusConfirmation >= 400 && reponseStatusConfirmation !== 403 &&
    +

    Une erreur s’est produite veuillez réessayer plus tard.

    +
    } + {!reponseStatusConfirmation && <> +

    + Appuyez sur le bouton pour confirmer votre email +

    + + } +
    + ); +} diff --git a/src/views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure.jsx b/src/views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure.jsx new file mode 100644 index 00000000..2baffab0 --- /dev/null +++ b/src/views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Header from '../../components/Header'; +import ConfirmationEmailCandidatureStructure from './ConfirmationEmailCandidatureStructure'; + +export default function PageConfirmationEmailCandidatureStructure() { + return ( + <> +
    + + + ); +} diff --git a/src/views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure.test.jsx b/src/views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure.test.jsx new file mode 100644 index 00000000..25de2c82 --- /dev/null +++ b/src/views/confirmation-email-candidature-structure/PageConfirmationEmailCandidatureStructure.test.jsx @@ -0,0 +1,125 @@ +import { render, act, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import ConfirmationEmailCandidatureStructure from './ConfirmationEmailCandidatureStructure'; + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ token: '1' }), +})); + +describe('confirmation Email', () => { + it('quand j’affiche la page de confirmation de l’email validée alors le titre et les informations de la page s’affichent', () => { + // WHEN + render(); + + // THEN + const titre = screen.getByRole('heading', { level: 1, name: 'Confirmation de l’enregistrement de votre candidature' }); + expect(titre).toBeInTheDocument(); + + const indication = screen.getByText('Appuyez sur le bouton pour confirmer votre email', + { selector: 'p' } + ); + expect(indication).toBeInTheDocument(); + + const envoyer = screen.getByRole('button', { name: 'Confirmer' }); + expect(envoyer).toBeInTheDocument(); + }); + + it('quand l’utilisateur clique sur le bouton et que le lien est valide alors un message de succès s’affiche', async () => { + // WHEN + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 200, json: async () => Promise.resolve({}) })) + ); + render(); + + const envoyer = screen.getByRole('button', { name: 'Confirmer' }); + expect(envoyer).toBeInTheDocument(); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + expect(envoyer).not.toBeInTheDocument(); + // THEN + const titre = screen.getByRole('heading', { level: 1, name: 'Confirmation de l’enregistrement de votre candidature' }); + expect(titre).toBeInTheDocument(); + + const messageSucess = screen.getByText('Votre email a été confirmé et votre inscription est maintenant active.' + + ' Vous recevrez un mail d’activation de votre espace structure lorsque votre candidature aura été validée.', + { selector: 'p' } + ); + expect(messageSucess).toBeInTheDocument(); + + const messageError403 = screen.queryByText('Le lien de validation de votre adresse électronique est invalide.', { selector: 'p' }); + expect(messageError403).not.toBeInTheDocument(); + + const messageErreurGenerale = screen.queryByText('Une erreur s’est produite veuillez réessayer plus tard.', { selector: 'p' }); + expect(messageErreurGenerale).not.toBeInTheDocument(); + }); + + it('quand l’utilisateur clique sur le bouton et que le lien est invalide alors un message d’erreur s’affiche', async () => { + + // GIVEN + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 403, json: async () => Promise.resolve({}) })) + ); + // WHEN + render(); + + // THEN + const envoyer = screen.getByRole('button', { name: 'Confirmer' }); + expect(envoyer).toBeInTheDocument(); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + expect(envoyer).not.toBeInTheDocument(); + // THEN + const titre = screen.getByRole('heading', { level: 1, name: 'Confirmation de l’enregistrement de votre candidature' }); + expect(titre).toBeInTheDocument(); + + const messageError = screen.getByText('Le lien de validation de votre adresse électronique est invalide.', + { selector: 'p' } + ); + expect(messageError).toBeInTheDocument(); + + const messageSuccess = screen.queryByText('Votre email a été confirmé et votre inscription est maintenant active.' + + ' Vous recevrez un mail d’activation de votre espace structure lorsque votre candidature aura été validée.', { selector: 'p' }); + expect(messageSuccess).not.toBeInTheDocument(); + const messageErreurGenerale = screen.queryByText('Une erreur s’est produite veuillez réessayer plus tard.', { selector: 'p' }); + expect(messageErreurGenerale).not.toBeInTheDocument(); + }); + + + it('quand l’utilisateur clique sur le bouton et qu’une erreur innatendue se produit alors un message d’erreur s’affiche', async () => { + // GIVEN + vi.stubGlobal('fetch', vi.fn( + () => ({ status: 500, json: async () => Promise.resolve({}) })) + ); + // WHEN + render(); + + // THEN + const envoyer = screen.getByRole('button', { name: 'Confirmer' }); + expect(envoyer).toBeInTheDocument(); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(() => { + fireEvent.click(envoyer); + }); + expect(envoyer).not.toBeInTheDocument(); + // THEN + const titre = screen.getByRole('heading', { level: 1, name: 'Confirmation de l’enregistrement de votre candidature' }); + expect(titre).toBeInTheDocument(); + + const messageError = screen.getByText('Une erreur s’est produite veuillez réessayer plus tard.', + { selector: 'p' } + ); + expect(messageError).toBeInTheDocument(); + + const messageError403 = screen.queryByText('Le lien de validation de votre adresse électronique est invalide.', { selector: 'p' }); + expect(messageError403).not.toBeInTheDocument(); + + const messageSuccess = screen.queryByText('Votre email a été confirmé et votre inscription est maintenant active.' + + ' Vous recevrez un mail d’activation de votre espace structure lorsque votre candidature aura été validée.', { selector: 'p' }); + expect(messageSuccess).not.toBeInTheDocument(); + + }); +}); diff --git a/src/views/confirmation-email-candidature-structure/useApiConfirmationEmailCandidatureStructure.js b/src/views/confirmation-email-candidature-structure/useApiConfirmationEmailCandidatureStructure.js new file mode 100644 index 00000000..1b61e551 --- /dev/null +++ b/src/views/confirmation-email-candidature-structure/useApiConfirmationEmailCandidatureStructure.js @@ -0,0 +1,17 @@ +export const useApiConfirmationEmailCandidatureStructure = () => { + + const actionConfirmationEmailCandidatureStructure = async token => { + const baseUrl = import.meta.env.VITE_APP_API_PILOTAGE_URL; + const requestOptions = { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + }; + try { + return await fetch(`${baseUrl}/confirmation-email-inscription-structure/${token}`, requestOptions); + } catch (error) { + return error; + } + }; + + return { actionConfirmationEmailCandidatureStructure }; +}; diff --git a/src/views/coordination-territoriale/CarteCoordinateur.js b/src/views/coordination-territoriale/CarteCoordinateur.js index a382ce73..42bc7b9f 100644 --- a/src/views/coordination-territoriale/CarteCoordinateur.js +++ b/src/views/coordination-territoriale/CarteCoordinateur.js @@ -1,11 +1,13 @@ import React, { Component } from 'react'; -import '@gouvfr-anct/cartographie-nationale/coordinateurs'; -const urlCoordinateurs = import.meta.env.VITE_APP_API_URL + '/coordinateurs'; -const urlConseillers = import.meta.env.VITE_APP_API_URL + '/coordination-conseillers'; +import '@gouvfr-anct/cartographie-nationale/coordinateurs'; +import '../../../public/styles-5.22.0.css'; class CarteCoordinateur extends Component { render() { + const urlCoordinateurs = import.meta.env.VITE_APP_API_URL + '/coordinateurs'; + const urlConseillers = import.meta.env.VITE_APP_API_URL + '/coordination-conseillers'; + return (
    @@ -70,12 +70,12 @@ function FormationInitiale() { Pour suivre une formation en France métropolitaine, rendez-vous sur le site de La Fabrik : -
      -
    1. Je clique sur « Inscription » et je réponds au questionnaire ;
    2. -
    3. j’effectue le test de positionnement ;
    4. -
    5. j’entre en formation.
    6. -

    +
      +
    1. Je clique sur « Inscription » et je réponds au questionnaire ;
    2. +
    3. j’effectue le test de positionnement ;
    4. +
    5. j’entre en formation.
    6. +
    -

    Les modules complémentaires

    diff --git a/src/views/candidature-conseiller/test-utils.js b/test/test-utils.js similarity index 58% rename from src/views/candidature-conseiller/test-utils.js rename to test/test-utils.js index 7b6975eb..029c5294 100644 --- a/src/views/candidature-conseiller/test-utils.js +++ b/test/test-utils.js @@ -1,3 +1,5 @@ export const textMatcher = wording => (_, element) => { return element?.textContent === wording; }; + +export const dateDujour = () => new Date().toISOString().slice(0, 10); diff --git a/vitest.setup.js b/vitest.setup.js index e8560114..a2915ef3 100644 --- a/vitest.setup.js +++ b/vitest.setup.js @@ -1 +1,6 @@ +import { vi } from 'vitest'; import 'vitest-dom/extend-expect'; + +// scrollIntoView is not implemented in jsdom +window.HTMLElement.prototype.scrollIntoView = vi.fn(); +window.scrollTo = vi.fn();