cross-gap.svgmenu-button.svg

React Starter

Starter React

Contributeurs

johan.jpg

Johan Korger

Lead Front-end

sam.jpg

Samuel Gomez

Lead Front-end

Le Besoin

CAPITALISER LES DÉVELOPPEMENTS AU FIL DES PROJETS

Chaque projet a contribué à l’amélioration du starter

NE PAS REFAIRE LES CONFIGURATIONS À CHAQUE PROJET

(Jest, MSW, Cucumber, Eslint, Typescript, Toolkit, OIDC, …)

RÉCUPÉRER LE SOCLE COMMUN

(Header, Footer, Router, Layout, etc …)

AMÉLIORATION DU TTM

Pouvoir démarrer un projet rapidement dans les environnements AXA avec une stack à jour

Stack technique

github.svg

Open Source

Sources disponiblent sur Github

typescript.svg

Typescript

Personnalisable dans le tsconfig.js

slash.svg

Toolkit Slash DS

Implémentation de Slash Design System

quality.svg

Quality

Eslint, Prettier, Husky, Sonar, Lint Staged

accessibility.svg

Accessibility

Eslint A11y, Axe Core, Jest Axe, A11yMenu

security.svg

Security

Authentification avec React OIDC

form.svg

Forms

Gestion des formulaires avec React Hook Form

router.svg

Routing

Système de routes avec React Router

fetch.svg

Fetch

Gestion des requêtes avec React Query

tests.svg

Tests

Vitest, Jest Cucumber, Testing Library, Jest Axe, Gherkins

Github

Le projet est Open Source, vous pouvez donc forker ou cloner le projet
Si vous rencontrez des bugs, vous pouvez créer une issue sur le repository.
Si vous souhaitez contribuer au projet, vous pouvez en faire la demande pour être ajouter à l'équipe.

Typescript

L'ensemble du projet a été développé avec Typescript
Si vous souhaitez adapter sa configuration, vous pouvez modifier le fichier tsconfig.

Slash Design System

Quality

Accessibility

Testing

testing-library.svg

Testing Library

Permet de tester le DOM et les interactions utilisateur

vitest.svg

Vitest

Pour personnaliser, modifiez le fichier vite.config.ts

cucumber.svg

Jest Cucumber

Pour les tests d'intégration, un dossier /features contient les scénarios Gherkin

msw.svg

MSW

Pour modifier les réponses d'api lors des tests : src/shared/testsUtils/msw.ts

React Query

React Query permet de faciliter la synchronisation entre le front et le back et offre des fonctionnalités qui facilitent la vie du développeur et améliorent l’expérience utilisateur.

React Hook Form

React Hook Form permet de gérer l'état et la validation des formulaires de manière performante et simple.

React Router

React Router est une librairie de routing basée sur la déclaration de routes
(contrairement à NextJS qui se base sur le file system)

Structure

App
C'est le core du starter, on va y trouver les providers communs ainsi que les routes
EnvironmentProvider
Provider permettant de récupérer les variables d'environnement. Il récupère la configuration dans les fichiers du dossier /public.
Authentication
Composant permettant de désactiver l'OIDC. si l'OIDC est activé, il utilise l'OidcProvider
UserProvider
Provider permettant de récupérer les infos utilisateur de l'OIDC
FetchProvider
Provider permettant de customiser le fetch (fetchCustom) en fonction de l'environnment et des infos de connexion OIDC (bearer) pour toute l'application.
QueryProvider
Ce provider instancie React Query en se basant sur le fetchCustom.
NotificationProvider
Ce provider permet de déclencher des notifications dans l'application
Routes
Ce composant sert à gérer les routes de l'application. Celles-ci sont chargées en lazy afin d'optimiser le bundle des pages.
RouteSecure
Ce composant permet de protéger une ou plusieurs routes par authentification. Il suffit de wrapper les routes souhaitées par ce composant. Il va véfifier dans le userContext si le role est autorisé à voir la page.
Layout
Ce composant permet d'obtenir un template de page commun et personnalisable.
Des propriétés permettent de masquer certaines parties comme le Header ou le Footer.
A11yMenu
Menu d'accès rapide, il est personnalisable depuis la page courante. (Accessibilité)
Header
Entete de l'application, il est basé sur les composants du Toolkit. Les infos utilisateurs sont récupérées depuis le contexte.
Footer
Footer de l'application, il est basé sur les composants du Toolkit. La version est récupérée depuis le package.json.
Menu
Menu de l'application, il est basé sur les composants Navbar du Toolkit. Utilisable au clavier.
TitleBar
Ce composant doit servir à situer l'utilisateur dans sa navigation (comme un fil d'ariane)
pages
C'est dans ce dossier que l'on va mettre les composants de page. D'une manière générale, la structure des dossiers doit refléter la structure de votre page.
Home
Page d'accueil de l'application
NotFound
Page 404 introuvable, basée sur le composant ResiliencePage
Unauthorize
Page 403 non autorisée, basée sur le composant ResiliencePage
shared
C'est dans ce dossier que l'on va mettre tout ce qui est commun au projet.
components
C'est dans ce dossier que l'on va mettre tous les composants partagés.
Authorize
Permet de rendre n'importe quel composant visible selon les profils autorisés.
form
Dossier contenant les champs de formulaire gérés par React Hook Form et basés sur les composants du Toolkit.
Grid
Composants permettant d'utiliser les grilles de Boostraps.
HelpInfo
Composant permettant d'ajouter un Tooltip sur n'importe quel composant.
Icon
Composant générique pour créer un svg avec un path. (déprécié)
Loader
Composant apportant des corrections sur celui du Toolkit(amené à être réintégré au Toolkit)
ModalCommon
Composant haut niveau basé sur le composant Modal du Toolkit.
Resilience
Composant wrapper qui peut être utilisé pour des appels asynchrones.
L'idée est d'afficher automatiquement un message d'alert ou un autre composant de fallback selon les réponses de l'API (200,404,500,...)
ResiliencePage
Composant template pour les pages comme les 404, 403, ...
Skeleton
Composant loader au style skeleton qui permet d'indiquer à l'utilisateur qu'une partie de l'application se charge sans devoir mettre un loader sur toute la page.
SkeletonInputField
Composant loader au style skeleton adapté aux champs de formulaire.
Table
Composant de plus haut niveau basé sur le Table du Toolkit. Il propose de base, du tri, de la pagination et des tooltips sur les cellules.
helpers
Dossier contenant des fonctions utilitaire javascript
hoc
Dossier contenant des HOC
scss
Dossier contenant du style pour le projet (grilles bootstrap, reset, variables, custom, mixins, keyframes)
testsUtils
Dossier contenant des utilitaires pour les tests comme un renderCustom pour adapter les contextes selon ses besoins, les endpoints pour MSW ou des scénarios type.

Get Started

Nous allons maintenant passer à la pratique.
Veuillez utiliser la navigation vers le bas pour suivre les étapes.

Clone du projet

Vous pouvez cloner le projet de la démo depuis l'url ci-dessous :

https://github.com/samuel-gomez/react-starter-vitejs.git

Clean du projet

Il faut ensuite nettoyer les éléments de la démo

npm run clean

Installation

Vous pouvez maintenant effectuer l'installation

npm i

Démarrer le projet

Vous pouvez lancer le projet en saisissant la commande suivante.

npm start

Le projet va démarrer à l'adresse : http://localhost:3000

Exo 1 : page liste

Commençons par créer une nouvelle page
Dans le dossier /pages, créez un nouveau dossier /People
le but sera de récupérer les données d'une API et de les afficher dans un tableau

Création de la page

src/pages/People/index.ts
export { default } from './People';
              
src/pages/People/People.tsx
import { TITLE_BAR, TITLE } from './constants';
      
const People = () => (
  <p>Ma page people</p>
);
      
export default People;
              

Ajouter le layout

src/pages/People/constants.ts
export const TITLE_BAR = 'Liste des gens';
export const TITLE = 'Tableau des gens';
export const ROUTE_URL_PEOPLE = 'people';
              
src/pages/People/People.tsx
      
              
on importe le Composant Layout
on importe les constantes
On définit le type du Composant People
On met à jour les props du Composant People
Composant Layout avec les propriétés propsTitle
On ajoute un titre
import Layout, { type TLayoutPage } from 'Layout'; import { TITLE_BAR, TITLE } from './constants'; export type TPeople = TLayoutPage; const People = ({ titleBar = TITLE_BAR, title = TITLE }: TPeople) => ( <Layout propsTitle={{ title: titleBar, backHome: true }}> <h2 className="af-title--content">{title}</h2> <p>Ma page people</p> </Layout> ); export default People;

Ajouter la route

src/App/Routes/constants.ts
              
on importe la route depuis le Composant People
On réexporte pour centraliser les routes
import { ROUTE_URL_UNAUTHORIZE as UNAUTHORIZE } from 'pages/Unauthorize/constants'; import { ROUTE_URL_PEOPLE as PEOPLE } from 'pages/People/constants'; const ROUTE_URLS = { HOME, PEOPLE, ... }; export default ROUTE_URLS;
src/App/Routes/Routes.tsx
              
on importe le Composant People
On ajoute la route dans le Router
const PageNotFound = lazy(() => import('pages/NotFound')); const PagePeople = lazy(() => import('pages/People')); ... <Routes> <Route element={<RouteSecureCmpt />}> <Route index path={ROUTE_URLS.HOME} element={<Home />} /> </Route> <Route index path={ROUTE_URLS.PEOPLE} element={<PagePeople />} /> ...

Ajouter le scénario de test

Feature/People/People.feature
              Feature: Page People
      En tant que profil autorisé, je souhaite pouvoir afficher la page People
    
      @RG1
      Scenario Outline: Affichage de la page People
        Given Je suis un utilisateur connu et connecté avec le profil "<profil>"
        When J'accède à la page People
        Then un titre "Tableau des gens" est visible
    
        Examples:
          | profil |
          | Admin  |
          | User   |
      
              

Ajouter le fichier de test

src/pages/People/__tests__/People.spec.tsx
              
On importe Jest Cucumber
On importe "configure" de Testing Library via le customRender
On inclut les éléments cachés pour l'accessibilité (doc)
On importe le fichier People.feature
On pose le test à vide
import { defineFeature, loadFeature } from 'jest-cucumber'; import { configure } from 'shared/testsUtils/customRender'; configure({ defaultHidden: true }); const feature = loadFeature('features/People/People.feature'); defineFeature(feature, test => { test('Affichage de la page People', ({ given, and, when, then }) => {}); });

Générer le test

Lancer la commande suivante :
                 npm t People.spec.tsx
              
Récupérez le code généré dans le terminal
gherkin test fail
src/pages/People/__tests__/People.spec.tsx
              defineFeature(feature, test => {
  test('Affichage de la page People', ({ given, when, then }) => {
    given(/^Je suis un utilisateur connu et connecté avec le profil (.*)$/, arg0 => {});
    when("J'accède à la page People", () => {});
    then(/^un titre "(.*)" est visible$/, arg0 => {});
  });
});
      
              

Modifier le test

src/pages/People/__tests__/People.spec.tsx
 
              
On importe "render" et "screen" de Testing Library
On importe les scénarios type
On importe notre composant de page
On mocke le role depuis les valeurs du scénario
On rend la page avec le rôle défini
On vérifie le nom de l'utilisateur
On vérifie la présence du titre de niveau 2
... import { configure, render, screen } from 'shared/testsUtils/customRender'; import { JeSuisUnUtilisateurConnuEtConnecteAvecleProfil, UnTitreEstVisible } from 'shared/testsUtils/sharedScenarios'; import People from '..'; ... defineFeature(feature, test => { let role: string; test('Affichage de la page People', ({ given, when, then }) => { JeSuisUnUtilisateurConnuEtConnecteAvecleProfil(given, (roleMock: string) => { role = roleMock; }); when("J'accède à la page People", async () => { render(<People />, {}, { role }); expect(await screen.findByText('Samuel Gomez')).toBeInTheDocument(); }); UnTitreEstVisible(then,2); }); });

Modifier le scénario

Feature/People/People.feature
 
              
On définie les données reçues via l'api que l'on va fournir à MSW
On vérifie la présence du tableau
On vérifie les entêtes du tableau
On vérifie le contenu du tableau
Given Je suis un utilisateur connu et connecté avec le profil "<profil>" And la page reçoit les données suivantes | _id | firstname | lastname | birthDate | photo | entity | manager | managerId | | 1 | Samuel | Gomez | 1983-10-20T00:00:00 | https://randomuser.me/portraits/men/34.jpg | BIOSPAN | Sophie | 4 | | 2 | John | Doe | 1978-10-20T00:00:00 | https://randomuser.me/portraits/men/34.jpg | PEARLESSA | Sophie | 4 | | 3 | Guillaume | Chervet | 1985-10-20T00:00:00 | https://randomuser.me/portraits/men/34.jpg | CIRCUM | Sophie | 4 | | 4 | Sophie | Danneels | 1992-10-20T00:00:00 | https://randomuser.me/portraits/women/85.jpg | TRIPSCH | | | When J'accède à la page People Then un titre "Tableau des gens" est visible And la page contient un tableau répertoriant la liste des gens And le tableau présente des entêtes de colonnes dans l’ordre suivant : "Prénom", "Nom", "Date de naissance", "Entité" And le tableau contient 4 lignes avec 4 colonnes dans l'ordre suivant : | firstname | lastname | birthdate | entity | | Samuel | Gomez | 20/10/1983 | BIOSPAN | | John | Doe | 20/10/1978 | PEARLESSA | | Guillaume | Chervet | 20/10/1985 | CIRCUM | | Sophie | Danneels | 20/10/1992 | TRIPSCH |

Modifier le fichier de test

                 npm t People.spec.tsx
              
src/pages/People/__tests__/People.spec.tsx
 test('Affichage de la page People', ({ given, when, then, and }) => {
  JeSuisUnUtilisateurConnuEtConnecteAvecleProfil(given, (roleMock: string) => {
    role = roleMock;
  });
  and('la page reçoit les données suivantes', table => {});
  when("J'accède à la page People", async () => {
    render(<People />, {}, { role });
    expect(await screen.findByText('Samuel Gomez')).toBeInTheDocument();
  });
  UnTitreEstVisible(then,2);
  and('la page contient un tableau répertoriant la liste des gens', () => {});
  and(/^le tableau présente des entêtes de colonnes dans l’ordre suivant : "(.*)", "(.*)", "(.*)", "(.*)"$/, (arg0, arg1, arg2, arg3) => {});
  and(/^le tableau contient (\d+) lignes avec (\d+) colonnes dans l'ordre suivant :$/, (arg0, arg1, table) => {});
});
              

Utiliser les scénarios type

src/pages/People/People/People.spec.tsx
 
              
On importe les scénarios type
On importe serverUseGet qui permet de mocker les appels réseaux en GET via MSW
On passe les données du gherkin à la méthode serverUseGet (le typage sera fait après)
Ensuite, on remplace les scénarios générés par les scénarios type
... import { JeSuisUnUtilisateurConnuEtConnecteAvecleProfil, LaPageContientUnTableau, LeTableauContientLesLignesCorrespondantAuxDonneesRecues, LeTableauPresenteDesEntetesDeColonnesDansLOrdreSuivant, UnTitreEstVisible, } from 'shared/testsUtils/sharedScenarios'; import { serverUseGet } from 'shared/testsUtils/msw'; import People from '..'; ... const tableItemsType = 'membres'; and('la page reçoit les données suivantes', responseBody => { serverUseGet<TPeopleData[]>({ route: 'people', responseBody }); // le typage sera fait juste après }); ... LaPageContientUnTableau(and, 'la page contient un tableau répertoriant la liste des gens', tableItemsType); LeTableauPresenteDesEntetesDeColonnesDansLOrdreSuivant( and, /^le tableau présente des entêtes de colonnes dans l’ordre suivant : "(.*)", "(.*)", "(.*)", "(.*)"$/, tableItemsType, ); LeTableauContientLesLignesCorrespondantAuxDonneesRecues( and, /^le tableau contient (\d+) lignes avec (\d+) colonnes dans l'ordre suivant :$/, tableItemsType, );

Définir l'url d'API

Avant de démarrer les développements, nous avons besoin de configurer notre url d'API
Ajouter l'url d'API dans le fichier /public/environment.development.json
              
apiUrl est sous forme d'objet pour permettre d'avoir plusieurs sources d'API
... { "apiUrl": { "base": "https://react-starter-api.vercel.app/api/" }, "baseUrl": "",

Ajouter les headers

On créé des constantes pour les entêtes de tableau et le endpoint d'API
src/pages/People/constants.ts
 
              
on créé une constante pour le nom du service
on créé une constante pour le endpoint
on créé une constante qui contiendra les infos du header pour le tableau
export const TITLE_BAR = 'Liste des gens'; export const TITLE = 'Tableau des gens'; export const ROUTE_URL_PEOPLE = 'people'; export const SERVICE_NAME = 'people'; export const ENDPOINT = 'people'; export const TABLE_HEADERS_PEOPLE = [ { label: 'Prénom', id: 'firstname', key: 'firstname' }, { label: 'Nom', id: 'lastname', key: 'lastname' }, { label: 'Date de naissance', id: 'birthDate', key: 'birthDate' }, { label: 'Entité', id: 'entity', key: 'entity' }, ];

Typer les données

src/pages/People/People.tsx : typage
 export type TPeopleData = Record<string, string>;
      
export type TPeopleDataResponse = {
  responseBody: TPeopleData[];
};
              

Récupération des données

src/pages/People/People.tsx : usePeople
 
                
On importe useQuery de React Query, setAnomalyEmptyItems, Tanomaly et nos constantes
import { useQuery } from '@tanstack/react-query'; import Layout, { type TLayoutPage } from 'Layout'; import { setAnomalyEmptyItems } from 'shared/helpers'; import { type Tanomaly } from 'shared/types'; import { ENDPOINT, SERVICE_NAME, TITLE, TITLE_BAR } from './constants'; export const usePeople = () => { const { data, isLoading, error, refetch } = useQuery({ queryKey: [ENDPOINT], select: ({ responseBody }: TPeopleDataResponse) => ({ anomaly: setAnomalyEmptyItems(responseBody), [SERVICE_NAME]: computeInfos(responseBody), }), }); return { ...data, anomaly: (error || data?.anomaly) as Tanomaly | null, isLoading, refetch, }; }; export type TReturnUsePeople = ReturnType<typeof usePeople>;
 
                
On créé un hook custom pour effectuer l'appel avec React Query
import { useQuery } from '@tanstack/react-query'; import Layout, { type TLayoutPage } from 'Layout'; import { setAnomalyEmptyItems } from 'shared/helpers'; import { type Tanomaly } from 'shared/types'; import { ENDPOINT, SERVICE_NAME, TITLE, TITLE_BAR } from './constants'; export const usePeople = () => { const { data, isLoading, error, refetch } = useQuery({ queryKey: [ENDPOINT], select: ({ responseBody }: TPeopleDataResponse) => ({ anomaly: setAnomalyEmptyItems(responseBody), [SERVICE_NAME]: computeInfos(responseBody), }), }); return { ...data, anomaly: (error || data?.anomaly) as Tanomaly | null, isLoading, refetch, }; }; export type TReturnUsePeople = ReturnType<typeof usePeople>;
 
                
On passe la route au useQuery, il nous retourne les données, l'état du fetch, l'état de l'erreur, et une méthode refetch
import { useQuery } from '@tanstack/react-query'; import Layout, { type TLayoutPage } from 'Layout'; import { setAnomalyEmptyItems } from 'shared/helpers'; import { type Tanomaly } from 'shared/types'; import { ENDPOINT, SERVICE_NAME, TITLE, TITLE_BAR } from './constants'; export const usePeople = () => { const { data, isLoading, error, refetch } = useQuery({ queryKey: [ENDPOINT], select: ({ responseBody }: TPeopleDataResponse) => ({ anomaly: setAnomalyEmptyItems(responseBody), [SERVICE_NAME]: computeInfos(responseBody), }), }); return { ...data, anomaly: (error || data?.anomaly) as Tanomaly | null, isLoading, refetch, }; }; export type TReturnUsePeople = ReturnType<typeof usePeople>;
 
                
En second paramètre, sur la propriété select, on peut passer une fonction qui s'exécutera après avoir reçu les données pour effectuer des traitements
import { useQuery } from '@tanstack/react-query'; import Layout, { type TLayoutPage } from 'Layout'; import { setAnomalyEmptyItems } from 'shared/helpers'; import { type Tanomaly } from 'shared/types'; import { ENDPOINT, SERVICE_NAME, TITLE, TITLE_BAR } from './constants'; export const usePeople = () => { const { data, isLoading, error, refetch } = useQuery({ queryKey: [ENDPOINT], select: ({ responseBody }: TPeopleDataResponse) => ({ anomaly: setAnomalyEmptyItems(responseBody), [SERVICE_NAME]: computeInfos(responseBody), }), }); return { ...data, anomaly: (error || data?.anomaly) as Tanomaly | null, isLoading, refetch, }; }; export type TReturnUsePeople = ReturnType<typeof usePeople>;
ici, on renvoie un objet contenant l'anomalie en cas de réponse à vide et les données formatées en cas de données non vides
 
                
setAnomalyEmptyItems est une fonction utilitaire qui va renvoyer un objet dans l'anomalie qui affichera une alert
import { useQuery } from '@tanstack/react-query'; import Layout, { type TLayoutPage } from 'Layout'; import { setAnomalyEmptyItems } from 'shared/helpers'; import { type Tanomaly } from 'shared/types'; import { ENDPOINT, SERVICE_NAME, TITLE, TITLE_BAR } from './constants'; export const usePeople = () => { const { data, isLoading, error, refetch } = useQuery({ queryKey: [ENDPOINT], select: ({ responseBody }: TPeopleDataResponse) => ({ anomaly: setAnomalyEmptyItems(responseBody), [SERVICE_NAME]: computeInfos(responseBody), }), }); return { ...data, anomaly: (error || data?.anomaly) as Tanomaly | null, isLoading, refetch, }; }; export type TReturnUsePeople = ReturnType<typeof usePeople>;
computeInfos est une fonction que l'on va créer pour respecter le format de données attendu par le tableau
 
                
Enfin, on retourne les valeurs utiles à la vue
import { useQuery } from '@tanstack/react-query'; import Layout, { type TLayoutPage } from 'Layout'; import { setAnomalyEmptyItems } from 'shared/helpers'; import { type Tanomaly } from 'shared/types'; import { ENDPOINT, SERVICE_NAME, TITLE, TITLE_BAR } from './constants'; export const usePeople = () => { const { data, isLoading, error, refetch } = useQuery({ queryKey: [ENDPOINT], select: ({ responseBody }: TPeopleDataResponse) => ({ anomaly: setAnomalyEmptyItems(responseBody), [SERVICE_NAME]: computeInfos(responseBody), }), }); return { ...data, anomaly: (error || data?.anomaly) as Tanomaly | null, isLoading, refetch, }; }; export type TReturnUsePeople = ReturnType<typeof usePeople>;

Formatage des données

src/pages/People/People.tsx : computeInfos
 
                
On modifie les imports
... import { setAnomalyEmptyItems, setDate } from 'shared/helpers'; import { setDisplay } from 'shared/components/Table'; ... export const computeInfos = (data: TPeopleData[]) => data?.map(({ _id, firstname, lastname, birthDate, entity }) => ({ key: _id, cols: { ...setDisplay({ firstname }), ...setDisplay({ lastname }), ...setDisplay({ birthDate: setDate({ date: birthDate }) }), ...setDisplay({ entity }), }, }));
 
                
La fonction reçoit les données brutes
... import { setAnomalyEmptyItems, setDate } from 'shared/helpers'; import { setDisplay } from 'shared/components/Table'; ... export const computeInfos = (data: TPeopleData[]) => data?.map(({ _id, firstname, lastname, birthDate, entity }) => ({ key: _id, cols: { ...setDisplay({ firstname }), ...setDisplay({ lastname }), ...setDisplay({ birthDate: setDate({ date: birthDate }) }), ...setDisplay({ entity }), }, }));
 
                
On boucle sur les données
... import { setAnomalyEmptyItems, setDate } from 'shared/helpers'; import { setDisplay } from 'shared/components/Table'; ... export const computeInfos = (data: TPeopleData[]) => data?.map(({ _id, firstname, lastname, birthDate, entity }) => ({ key: _id, cols: { ...setDisplay({ firstname }), ...setDisplay({ lastname }), ...setDisplay({ birthDate: setDate({ date: birthDate }) }), ...setDisplay({ entity }), }, }));
 
                
Pour chaque item, on renvoie les données formatées
... import { setAnomalyEmptyItems, setDate } from 'shared/helpers'; import { setDisplay } from 'shared/components/Table'; ... export const computeInfos = (data: TPeopleData[]) => data?.map(({ _id, firstname, lastname, birthDate, entity }) => ({ key: _id, cols: { ...setDisplay({ firstname }), ...setDisplay({ lastname }), ...setDisplay({ birthDate: setDate({ date: birthDate }) }), ...setDisplay({ entity }), }, }));
 
                
La méthode setDisplay formate les données pour chaque cellule du tableau
... import { setAnomalyEmptyItems, setDate } from 'shared/helpers'; import { setDisplay } from 'shared/components/Table'; ... export const computeInfos = (data: TPeopleData[]) => data?.map(({ _id, firstname, lastname, birthDate, entity }) => ({ key: _id, cols: { ...setDisplay({ firstname }), ...setDisplay({ lastname }), ...setDisplay({ birthDate: setDate({ date: birthDate }) }), ...setDisplay({ entity }), }, }));
cols: {
    ...
    entity: {
      classModifier: 'actions',
      children: <Badge classModifier="info">{entity}</Badge>
    },
              

Passage des données à la vue

src/pages/People/People.tsx : PeopleContainer
 
                
On importe la fonction setLoaderMode
... import { setLoaderMode } from 'shared/components/Loader'; ... const PeopleContainer = () => { const { anomaly, isLoading, people, refetch } = usePeople(); return <People people={people} loaderMode={setLoaderMode({ isLoading })} refetch={refetch} anomaly={anomaly} />; }; export default PeopleContainer;
 
                
On déclare un nouveau composant
... import { setLoaderMode } from 'shared/components/Loader'; ... const PeopleContainer = () => { const { anomaly, isLoading, people, refetch } = usePeople(); return <People people={people} loaderMode={setLoaderMode({ isLoading })} refetch={refetch} anomaly={anomaly} />; }; export default PeopleContainer;
 
                
On récupère les données via notre hook custom
... import { setLoaderMode } from 'shared/components/Loader'; ... const PeopleContainer = () => { const { anomaly, isLoading, people, refetch } = usePeople(); return <People people={people} loaderMode={setLoaderMode({ isLoading })} refetch={refetch} anomaly={anomaly} />; }; export default PeopleContainer;
 
                
On les passe à la vue
... import { setLoaderMode } from 'shared/components/Loader'; ... const PeopleContainer = () => { const { anomaly, isLoading, people, refetch } = usePeople(); return <People people={people} loaderMode={setLoaderMode({ isLoading })} refetch={refetch} anomaly={anomaly} />; }; export default PeopleContainer;
 
                
On modifie l'export par défaut
... import { setLoaderMode } from 'shared/components/Loader'; ... const PeopleContainer = () => { const { anomaly, isLoading, people, refetch } = usePeople(); return <People people={people} loaderMode={setLoaderMode({ isLoading })} refetch={refetch} anomaly={anomaly} />; }; export default PeopleContainer;

Modifier la vue

src/pages/People/People.tsx : People
 
                
On ajoute les imports nécessaires
... import Loader, { TLoader, setLoaderMode } from 'shared/components/Loader'; import Resilience from 'shared/components/Resilience'; import Table, { setDisplay } from 'shared/components/Table'; import { ENDPOINT, SERVICE_NAME, TABLE_HEADERS_PEOPLE, TITLE, TITLE_BAR } from './constants'; ... export type TPeople = TLayoutPage & Pick<TReturnUsePeople, 'people' | 'anomaly' | 'refetch'> & { loaderMode: TLoader['mode']; headers?: typeof TABLE_HEADERS_PEOPLE; }; const People = ({ titleBar = TITLE_BAR, title = TITLE, people, headers = TABLE_HEADERS_PEOPLE, refetch, loaderMode, anomaly }: TPeople) => ( <Layout propsTitle={{ title: titleBar, backHome: true }}> <h2 className="af-title--content">{title}</h2> <Loader mode={loaderMode}> <Resilience anomaly={anomaly} refetch={refetch as React.MouseEventHandler<HTMLButtonElement>}> <Table title="Titre de mon tableau" items={people} headers={headers} itemsType="membres" /> </Resilience> </Loader> </Layout> );
 
                
On modifie le type de People
... import Loader, { TLoader, setLoaderMode } from 'shared/components/Loader'; import Resilience from 'shared/components/Resilience'; import Table, { setDisplay } from 'shared/components/Table'; import { ENDPOINT, SERVICE_NAME, TABLE_HEADERS_PEOPLE, TITLE, TITLE_BAR } from './constants'; ... export type TPeople = TLayoutPage & Pick<TReturnUsePeople, 'people' | 'anomaly' | 'refetch'> & { loaderMode: TLoader['mode']; headers?: typeof TABLE_HEADERS_PEOPLE; }; const People = ({ titleBar = TITLE_BAR, title = TITLE, people, headers = TABLE_HEADERS_PEOPLE, refetch, loaderMode, anomaly }: TPeople) => ( <Layout propsTitle={{ title: titleBar, backHome: true }}> <h2 className="af-title--content">{title}</h2> <Loader mode={loaderMode}> <Resilience anomaly={anomaly} refetch={refetch as React.MouseEventHandler<HTMLButtonElement>}> <Table title="Titre de mon tableau" items={people} headers={headers} itemsType="membres" /> </Resilience> </Loader> </Layout> );
 
                
On modifie les props d'entrée
... import Loader, { TLoader, setLoaderMode } from 'shared/components/Loader'; import Resilience from 'shared/components/Resilience'; import Table, { setDisplay } from 'shared/components/Table'; import { ENDPOINT, SERVICE_NAME, TABLE_HEADERS_PEOPLE, TITLE, TITLE_BAR } from './constants'; ... export type TPeople = TLayoutPage & Pick<TReturnUsePeople, 'people' | 'anomaly' | 'refetch'> & { loaderMode: TLoader['mode']; headers?: typeof TABLE_HEADERS_PEOPLE; }; const People = ({ titleBar = TITLE_BAR, title = TITLE, people, headers = TABLE_HEADERS_PEOPLE, refetch, loaderMode, anomaly }: TPeople) => ( <Layout propsTitle={{ title: titleBar, backHome: true }}> <h2 className="af-title--content">{title}</h2> <Loader mode={loaderMode}> <Resilience anomaly={anomaly} refetch={refetch as React.MouseEventHandler<HTMLButtonElement>}> <Table title="Titre de mon tableau" items={people} headers={headers} itemsType="membres" /> </Resilience> </Loader> </Layout> );
 
                
On ajoute le Loader qui affichera un
loader en fonction de l'état de chargement
... import Loader, { TLoader, setLoaderMode } from 'shared/components/Loader'; import Resilience from 'shared/components/Resilience'; import Table, { setDisplay } from 'shared/components/Table'; import { ENDPOINT, SERVICE_NAME, TABLE_HEADERS_PEOPLE, TITLE, TITLE_BAR } from './constants'; ... export type TPeople = TLayoutPage & Pick<TReturnUsePeople, 'people' | 'anomaly' | 'refetch'> & { loaderMode: TLoader['mode']; headers?: typeof TABLE_HEADERS_PEOPLE; }; const People = ({ titleBar = TITLE_BAR, title = TITLE, people, headers = TABLE_HEADERS_PEOPLE, refetch, loaderMode, anomaly }: TPeople) => ( <Layout propsTitle={{ title: titleBar, backHome: true }}> <h2 className="af-title--content">{title}</h2> <Loader mode={loaderMode}> <Resilience anomaly={anomaly} refetch={refetch as React.MouseEventHandler<HTMLButtonElement>}> <Table title="Titre de mon tableau" items={people} headers={headers} itemsType="membres" /> </Resilience> </Loader> </Layout> );
 
                
On ajoute le composant Resilience
qui affichera un fallback en cas d'anomalie
... import Loader, { TLoader, setLoaderMode } from 'shared/components/Loader'; import Resilience from 'shared/components/Resilience'; import Table, { setDisplay } from 'shared/components/Table'; import { ENDPOINT, SERVICE_NAME, TABLE_HEADERS_PEOPLE, TITLE, TITLE_BAR } from './constants'; ... export type TPeople = TLayoutPage & Pick<TReturnUsePeople, 'people' | 'anomaly' | 'refetch'> & { loaderMode: TLoader['mode']; headers?: typeof TABLE_HEADERS_PEOPLE; }; const People = ({ titleBar = TITLE_BAR, title = TITLE, people, headers = TABLE_HEADERS_PEOPLE, refetch, loaderMode, anomaly }: TPeople) => ( <Layout propsTitle={{ title: titleBar, backHome: true }}> <h2 className="af-title--content">{title}</h2> <Loader mode={loaderMode}> <Resilience anomaly={anomaly} refetch={refetch as React.MouseEventHandler<HTMLButtonElement>}> <Table title="Titre de mon tableau" items={people} headers={headers} itemsType="membres" /> </Resilience> </Loader> </Layout> );
 
                
On ajoute le composant Table
qui affichera le tableau si tout est ok
... import Loader, { TLoader, setLoaderMode } from 'shared/components/Loader'; import Resilience from 'shared/components/Resilience'; import Table, { setDisplay } from 'shared/components/Table'; import { ENDPOINT, SERVICE_NAME, TABLE_HEADERS_PEOPLE, TITLE, TITLE_BAR } from './constants'; ... export type TPeople = TLayoutPage & Pick<TReturnUsePeople, 'people' | 'anomaly' | 'refetch'> & { loaderMode: TLoader['mode']; headers?: typeof TABLE_HEADERS_PEOPLE; }; const People = ({ titleBar = TITLE_BAR, title = TITLE, people, headers = TABLE_HEADERS_PEOPLE, refetch, loaderMode, anomaly }: TPeople) => ( <Layout propsTitle={{ title: titleBar, backHome: true }}> <h2 className="af-title--content">{title}</h2> <Loader mode={loaderMode}> <Resilience anomaly={anomaly} refetch={refetch as React.MouseEventHandler<HTMLButtonElement>}> <Table title="Titre de mon tableau" items={people} headers={headers} itemsType="membres" /> </Resilience> </Loader> </Layout> );

Couverture de test

Lancer la commande suivante :
                 npm run cover
              
Pour compléter nos tests, nous allons ajouter un peu de Tests Unitaires

Ajout de tests unitaires

src/pages/People/__tests__/People.test.tsx
                       
                
On importe waitFor et customRenderHook.
import { customRenderHook, waitFor } from 'shared/testsUtils'; import { computeInfos, usePeople } from '../People';
customRenderHook permet de modifier les données renvoyée par React Query
                         const peopleMock = [
  {
    _id: '99999',
    firstname: 'Samuel',
    lastname: 'Gomez',
    birthDate: '1985-10-20T13:44:20.540000',
    entity: 'AXA',
  },
];
                const expectedData = [
  {
    cols: {
      birthDate: {
        label: '20/10/1985',
      },
      firstname: {
        label: 'Samuel',
      },
      lastname: {
        label: 'Gomez',
      },
      entity: {
        label: 'AXA',
      },
    },
    key: '99999',
  },
];
                const expectedEmptyAnomaly = {
  label: 'Info : Aucune donnée trouvée',
  type: 'info',
  iconName: 'exclamation-sign',
};
                
 
                
Cas avec des données
describe('computeInfos', () => { it('Should computed people when computeInfos have been called with people', () => { const computedPeople = computeInfos(peopleMock); expect(computedPeople).toMatchObject(expectedData); }); it('Should empty array when computeInfos have been called with empty people', () => { const computedPeople = computeInfos([]); expect(computedPeople).toEqual([]); }); });
 
                
Cas données vides
describe('computeInfos', () => { it('Should computed people when computeInfos have been called with people', () => { const computedPeople = computeInfos(peopleMock); expect(computedPeople).toMatchObject(expectedData); }); it('Should empty array when computeInfos have been called with empty people', () => { const computedPeople = computeInfos([]); expect(computedPeople).toEqual([]); }); });
 
                
Cas données vides
describe('usePeople', () => { it.each` queryData | people | isLoading | anomaly ${{ responseBody: [] }} | ${[]} | ${false} | ${expectedEmptyAnomaly} ${{ responseBody: peopleMock }} | ${expectedData} | ${false} | ${null} `( 'Should return isLoading: $isLoading, anomaly: $anomaly, people: $people when usePeople is rendered with queryData: $queryData', async ({ queryData, anomaly, isLoading, people }) => { const { result } = customRenderHook({ queryData })(() => usePeople(), {}); await waitFor(() => expect(result.current).toMatchObject({ anomaly, isLoading, people, refetch: result.current.refetch, }), ); }, ); });
 
                
Cas avec des données
describe('usePeople', () => { it.each` queryData | people | isLoading | anomaly ${{ responseBody: [] }} | ${[]} | ${false} | ${expectedEmptyAnomaly} ${{ responseBody: peopleMock }} | ${expectedData} | ${false} | ${null} `( 'Should return isLoading: $isLoading, anomaly: $anomaly, people: $people when usePeople is rendered with queryData: $queryData', async ({ queryData, anomaly, isLoading, people }) => { const { result } = customRenderHook({ queryData })(() => usePeople(), {}); await waitFor(() => expect(result.current).toMatchObject({ anomaly, isLoading, people, refetch: result.current.refetch, }), ); }, ); });
 
                
On rend notre hook en passant le mock que doit renvoyer React Query
describe('usePeople', () => { it.each` queryData | people | isLoading | anomaly ${{ responseBody: [] }} | ${[]} | ${false} | ${expectedEmptyAnomaly} ${{ responseBody: peopleMock }} | ${expectedData} | ${false} | ${null} `( 'Should return isLoading: $isLoading, anomaly: $anomaly, people: $people when usePeople is rendered with queryData: $queryData', async ({ queryData, anomaly, isLoading, people }) => { const { result } = customRenderHook({ queryData })(() => usePeople(), {}); await waitFor(() => expect(result.current).toMatchObject({ anomaly, isLoading, people, refetch: result.current.refetch, }), ); }, ); });
 describe('usePeople', () => {
  it.each`
    queryData                       | people          | isLoading | anomaly
    ${{ responseBody: [] }}         | ${[]}           | ${false}  | ${expectedEmptyAnomaly}
    ${{ responseBody: peopleMock }} | ${expectedData} | ${false}  | ${null}
  `(
    'Should return isLoading: $isLoading, anomaly: $anomaly, people: $people when usePeople is rendered with queryData: $queryData',
    async ({ queryData, anomaly, isLoading, people }) => {
      const { result } = customRenderHook({ queryData })(() => usePeople(), {});

      await waitFor(() =>
        expect(result.current).toMatchObject({
          anomaly,
          isLoading,
          people,
          refetch: result.current.refetch,
        }),
      );
    },
  );
});
                

Solution

Dispo sur la branche feature/dojo sur Github

Exo 2 : Détail du contrat

Afficher le détail du contrat au clic du bouton

prototype détail du contrat

Spécifications

  • Le but est d'ajouter une colonne avec pour libellé d'entête "Actions"
  • Pour chaque ligne du tableau, il faut afficher un bouton style "pastille" dans la dernière colonne
  • Lorsque l'utilisateur clique sur ce bouton, une modale apparait
  • Le titre de la modal contient "Détail de : Prénom Nom"
  • La modale affiche en premier l'avatar de la personne (centré)
  • En dessous, le Nom Prénom de la personne (titre bleu centré)
  • En dessous, le nom de l'entité (centré)
  • En dessous, dans un encart de restitution, on récupère (appel asynchrone) le détail du contrat
  • Le titre de l'encart contient "Détail du contrat"
  • Afficher les données telles que prévues dans le prototype
  • En bas à droite de la modal, un bouton "Fermer" est présent, le clic ferme la modale

Le prototype

Ouvrir le prototype dans un onglet

Infos complémentaires

  • l'url d'API pour récupérer le détail utilisateur : /people/:id
  • pour afficher la modale, on peut utiliser le composant ModalCustom du starter Custom Modal
  • pour afficher les détails utiliser le composant Restitution
  • voici le code pour le bouton icone
    <Button id="uniqueid" className="af-btn--circle" >
          <i role="img" aria-label="eye-open" className="glyphicon glyphicon-eye-open" />
    </Button>
                      

Solution

Dispo sur la branche feature/dojo sur Github