test-tp1-ynov-react-kleas17 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # Application d'inscription SPA
2
+
3
+ ## Fonctionnalités
4
+
5
+ - Navigation SPA avec `react-router-dom`
6
+ - `/` : accueil, compteur des inscrits, liste des inscrits (Nom + Prénom)
7
+ - `/register` : formulaire d'inscription
8
+ - État partagé au niveau racine (tableau des utilisateurs)
9
+ - Données chargées/sauvegardées via API (`/users`) avec `axios`
10
+ - Résilience UI face aux erreurs backend :
11
+ - `400` : affichage du message métier renvoyé par le serveur
12
+ - `500` : affichage d'une alerte utilisateur sans crash de l'application
13
+
14
+ ## Architecture API
15
+
16
+ - Fichier dédié : `src/api.js`
17
+ - API par défaut : `https://jsonplaceholder.typicode.com`
18
+ - Variables d'environnement prises en charge :
19
+ - `REACT_APP_API_URL`
20
+ - `REACT_APP_SERVER_PORT`
21
+ - `REACT_APP_API_TOKEN` (header `Authorization: Bearer ...`)
22
+
23
+ ## Pyramide de tests
24
+
25
+ - UT : logique pure dans `src/validator.js`
26
+ - IT : interactions UI + rendu + navigation dans `src/App.test.js`
27
+ - IT API : appels réseau mockés dans `src/api.test.js` avec `jest.mock('axios')`
28
+ - E2E : parcours multi-vues avec `cy.intercept` dans `cypress/e2e/navigation.cy.js`
29
+
30
+ ## Couverture des cas (activité 5)
31
+
32
+ - Succès (`200/201`)
33
+ - Erreur métier (`400`) avec message backend visible
34
+ - Erreur serveur (`500`) avec message utilisateur de résilience
35
+ - Aucun appel réseau réel dans Jest (axios entièrement mocké)
36
+
37
+ ## Commandes
38
+
39
+ Dans `my-app` :
40
+
41
+ ```bash
42
+ npm install
43
+ npm start
44
+ npm test
45
+ npm run cypress:run
46
+ ```
47
+
48
+ ## CI/CD
49
+
50
+ Le workflow GitHub Actions exécute :
51
+
52
+ 1. tests Jest
53
+ 2. tests E2E Cypress
54
+ 3. build React
55
+ 4. publication GitHub Pages
56
+
57
+ Le workflow injecte :
58
+
59
+ - `REACT_APP_API_URL=https://jsonplaceholder.typicode.com`
60
+ - `REACT_APP_API_TOKEN=${{ secrets.JSONPLACEHOLDER_TOKEN }}`
61
+
62
+ ## Publication npm et SemVer
63
+
64
+ Le package est configuré pour la publication npm avec :
65
+
66
+ - `name: test-tp1-ynov-react-kleas17`
67
+ - `version` au format SemVer `MAJOR.MINOR.PATCH`
68
+ - `main: dist/index.js`
69
+ - `files: ["dist"]`
70
+
71
+ Commandes de versioning prises en charge :
72
+
73
+ ```bash
74
+ npm version major
75
+ npm version minor
76
+ npm version patch
77
+ npm version prerelease
78
+ npm version premajor
79
+ npm version preminor
80
+ npm version prepatch
81
+ npm version patch -m "Upgrade to %s"
82
+ ```
83
+
84
+ Pré-releases supportées :
85
+
86
+ ```bash
87
+ npm run version:alpha # x.y.z-alpha.n
88
+ npm run version:beta # x.y.z-beta.n
89
+ npm run version:rc # x.y.z-rc.n
90
+ ```
91
+
92
+ Validation manuelle de publication :
93
+
94
+ ```bash
95
+ npm run build-npm
96
+ npm adduser
97
+ npm publish
98
+ ```
99
+
100
+ Sécurité :
101
+
102
+ - Créer un token npm de type `Automation`.
103
+ - Ajouter `NPM_TOKEN` dans `Settings > Secrets and variables > Actions`.
104
+ - Ne jamais commiter un `.npmrc` contenant un token.
105
+ - Ne jamais republier une version existante : incrémenter systématiquement la version avant publication.
package/dist/App.css ADDED
@@ -0,0 +1,196 @@
1
+ .App {
2
+ background: linear-gradient(140deg, #062e72 0%, #16509f 45%, #fff6ec 100%);
3
+ min-height: 100vh;
4
+ padding: 2.25rem 1rem;
5
+ display: flex;
6
+ justify-content: center;
7
+ }
8
+
9
+ .form-container {
10
+ background: #ffffff;
11
+ border-radius: 14px;
12
+ width: min(100%, 820px);
13
+ padding: 1.5rem 1.4rem;
14
+ box-shadow: 0 16px 30px rgba(14, 42, 78, 0.14);
15
+ border: 1px solid #dbe8ff;
16
+ }
17
+
18
+ h1 {
19
+ margin: 0 0 1.15rem;
20
+ font-size: 1.5rem;
21
+ color: #0f3f75;
22
+ letter-spacing: 0.01em;
23
+ }
24
+
25
+ .registered-list {
26
+ margin: 0.6rem 0 1rem;
27
+ padding-left: 1.15rem;
28
+ }
29
+
30
+ .registered-item {
31
+ margin-bottom: 0.3rem;
32
+ color: #204b7f;
33
+ font-weight: 600;
34
+ }
35
+
36
+ .readme-link {
37
+ margin: -0.3rem 0 1rem;
38
+ text-align: center;
39
+ }
40
+
41
+ form {
42
+ display: grid;
43
+ grid-template-columns: repeat(2, minmax(0, 1fr));
44
+ column-gap: 0.95rem;
45
+ row-gap: 0.25rem;
46
+ }
47
+
48
+ .field-group:nth-child(3),
49
+ .field-group:nth-child(4),
50
+ .field-group:nth-child(5),
51
+ .field-group:nth-child(6) {
52
+ margin-top: 0.2rem;
53
+ }
54
+
55
+ .field-group {
56
+ display: grid;
57
+ gap: 0.35rem;
58
+ margin-bottom: 0.6rem;
59
+ padding: 0.75rem;
60
+ border: 1px solid #e1ebfb;
61
+ border-radius: 10px;
62
+ background: #f7faff;
63
+ }
64
+
65
+ label {
66
+ font-size: 0.88rem;
67
+ font-weight: 600;
68
+ color: #255a96;
69
+ }
70
+
71
+ input {
72
+ padding: 0.58rem 0.68rem;
73
+ border: 1px solid #c8dbf9;
74
+ border-radius: 8px;
75
+ font-size: 1rem;
76
+ background: #ffffff;
77
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
78
+ }
79
+
80
+ input:focus {
81
+ outline: none;
82
+ border-color: #02448d;
83
+ box-shadow: 0 0 0 3px rgba(47, 115, 190, 0.18);
84
+ }
85
+
86
+ input[aria-invalid="true"] {
87
+ border-color: #e65d1f;
88
+ }
89
+
90
+ .error-text {
91
+ color: #cf4f15;
92
+ font-size: 0.85rem;
93
+ margin: 0;
94
+ }
95
+
96
+ .submit-button {
97
+ width: 100%;
98
+ grid-column: 1 / -1;
99
+ margin-top: 0.35rem;
100
+ padding: 0.7rem 1rem;
101
+ border: none;
102
+ border-radius: 8px;
103
+ background: #03448d;
104
+ color: #ffffff;
105
+ font-size: 1rem;
106
+ font-weight: 600;
107
+ cursor: pointer;
108
+ transition: background-color 0.2s ease, transform 0.12s ease, box-shadow 0.2s ease;
109
+ }
110
+
111
+ .submit-button.disabled {
112
+ background: #f4a76e;
113
+ cursor: not-allowed;
114
+ }
115
+
116
+ .submit-button:not(.disabled):hover {
117
+ background: #02376f;
118
+ transform: translateY(-1px);
119
+ }
120
+
121
+ .submit-button:not(.disabled):focus-visible {
122
+ outline: none;
123
+ box-shadow: 0 0 0 3px rgba(47, 115, 190, 0.22);
124
+ }
125
+
126
+ .action-button {
127
+ display: inline-block;
128
+ text-decoration: none;
129
+ text-align: center;
130
+ border: none;
131
+ border-radius: 8px;
132
+ padding: 0.7rem 1rem;
133
+ font-size: 1rem;
134
+ font-weight: 600;
135
+ cursor: pointer;
136
+ transition: background-color 0.2s ease, transform 0.12s ease, box-shadow 0.2s ease;
137
+ }
138
+
139
+ .action-button:focus-visible {
140
+ outline: none;
141
+ box-shadow: 0 0 0 3px rgba(47, 115, 190, 0.22);
142
+ }
143
+
144
+ .primary-action {
145
+ background: #03448d;
146
+ color: #ffffff;
147
+ }
148
+
149
+ .primary-action:hover {
150
+ background: #02376f;
151
+ transform: translateY(-1px);
152
+ }
153
+
154
+ .secondary-action {
155
+ grid-column: 1 / -1;
156
+ margin-top: 0.45rem;
157
+ background: #ecf3ff;
158
+ color: #0f3f75;
159
+ border: 1px solid #c6daf8;
160
+ }
161
+
162
+ .secondary-action:hover {
163
+ background: #dfeeff;
164
+ }
165
+
166
+ .link-button {
167
+ box-sizing: border-box;
168
+ }
169
+
170
+ .toast {
171
+ margin-top: 0.95rem;
172
+ background: #fef3e9;
173
+ color: #9b3f11;
174
+ border: 1px solid #f6c7a4;
175
+ border-radius: 8px;
176
+ padding: 0.65rem 0.75rem;
177
+ font-size: 0.95rem;
178
+ }
179
+
180
+ @media (max-width: 500px) {
181
+ form {
182
+ grid-template-columns: 1fr;
183
+ }
184
+
185
+ .form-container {
186
+ padding: 1rem;
187
+ }
188
+
189
+ .submit-button {
190
+ margin-top: 0.2rem;
191
+ }
192
+
193
+ .secondary-action {
194
+ margin-top: 0.3rem;
195
+ }
196
+ }
package/dist/App.js ADDED
@@ -0,0 +1,232 @@
1
+ import './App.css';
2
+ import { BrowserRouter, Link, Navigate, Route, Routes, useNavigate } from 'react-router-dom';
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import { ValidationError, validateAge, validateEmail, validateIdentity, validatePostalCode, validateUniqueEmail } from './validator';
5
+ import { createRegistration, getRegistrations } from './api';
6
+ const initialValues = {
7
+ nom: '',
8
+ prenom: '',
9
+ email: '',
10
+ dateNaissance: '',
11
+ cp: '',
12
+ ville: ''
13
+ };
14
+ const fieldLabels = {
15
+ nom: 'Nom',
16
+ prenom: 'Prénom',
17
+ email: 'Email',
18
+ dateNaissance: 'Date de naissance',
19
+ cp: 'Code postal',
20
+ ville: 'Ville'
21
+ };
22
+
23
+ /**
24
+ * Runs all form validations and returns an object keyed by field name.
25
+ * @param {{nom:string, prenom:string, email:string, dateNaissance:string, cp:string, ville:string}} values
26
+ * @param {Array<{email:string}>} users
27
+ * @returns {Record<string, string>} Field-level validation errors.
28
+ */
29
+ function validateForm(values, users) {
30
+ const nextErrors = {};
31
+ const runValidation = (field, validator) => {
32
+ try {
33
+ validator();
34
+ } catch (error) {
35
+ if (error instanceof ValidationError) {
36
+ nextErrors[field] = error.message;
37
+ return;
38
+ }
39
+ nextErrors[field] = 'Erreur de validation';
40
+ }
41
+ };
42
+ runValidation('nom', () => validateIdentity(values.nom.trim()));
43
+ runValidation('prenom', () => validateIdentity(values.prenom.trim()));
44
+ runValidation('ville', () => validateIdentity(values.ville.trim()));
45
+ runValidation('email', () => validateEmail(values.email.trim()));
46
+ if (!nextErrors.email) {
47
+ runValidation('email', () => validateUniqueEmail(values.email, users));
48
+ }
49
+ runValidation('cp', () => validatePostalCode(values.cp.trim()));
50
+ runValidation('dateNaissance', () => validateAge(new Date(values.dateNaissance)));
51
+ return nextErrors;
52
+ }
53
+ function HomePage({
54
+ users,
55
+ onStartRegistration
56
+ }) {
57
+ return /*#__PURE__*/React.createElement("section", {
58
+ "data-cy": "home-page"
59
+ }, /*#__PURE__*/React.createElement("h1", null, "Bienvenue sur l'application d'inscription"), /*#__PURE__*/React.createElement("p", {
60
+ "data-cy": "registered-count"
61
+ }, users.length, " utilisateur(s) inscrit(s)"), users.length === 0 ? /*#__PURE__*/React.createElement("p", {
62
+ "data-cy": "empty-list"
63
+ }, "Aucun utilisateur inscrit pour le moment.") : /*#__PURE__*/React.createElement("ul", {
64
+ "data-cy": "registered-list",
65
+ className: "registered-list"
66
+ }, users.map((user, index) => /*#__PURE__*/React.createElement("li", {
67
+ key: `${user.email}-${index}`,
68
+ "data-cy": "registered-user",
69
+ className: "registered-item"
70
+ }, user.nom, " ", user.prenom))), /*#__PURE__*/React.createElement(Link, {
71
+ to: "/register",
72
+ "data-cy": "go-to-register",
73
+ className: "action-button primary-action link-button",
74
+ onClick: onStartRegistration
75
+ }, "Ajouter un utilisateur"));
76
+ }
77
+ function RegisterPage({
78
+ users,
79
+ onRegister
80
+ }) {
81
+ const navigate = useNavigate();
82
+ const [values, setValues] = useState(initialValues);
83
+ const [touched, setTouched] = useState({});
84
+ const errors = useMemo(() => validateForm(values, users), [values, users]);
85
+ const isValid = Object.keys(errors).length === 0;
86
+ const onChange = event => {
87
+ const {
88
+ name,
89
+ value
90
+ } = event.target;
91
+ setValues(previous => ({
92
+ ...previous,
93
+ [name]: value
94
+ }));
95
+ };
96
+ const onBlur = event => {
97
+ const {
98
+ name
99
+ } = event.target;
100
+ setTouched(previous => ({
101
+ ...previous,
102
+ [name]: true
103
+ }));
104
+ };
105
+ const onSubmit = async event => {
106
+ event.preventDefault();
107
+ if (!isValid) {
108
+ return;
109
+ }
110
+ const registrationSucceeded = await onRegister(values);
111
+ if (!registrationSucceeded) {
112
+ return;
113
+ }
114
+ setValues(initialValues);
115
+ setTouched({});
116
+ navigate('/');
117
+ };
118
+ const shouldShowError = fieldName => Boolean(touched[fieldName] || values[fieldName]);
119
+ return /*#__PURE__*/React.createElement("section", {
120
+ "data-cy": "register-page"
121
+ }, /*#__PURE__*/React.createElement("h1", null, "Formulaire utilisateur"), /*#__PURE__*/React.createElement("form", {
122
+ onSubmit: onSubmit,
123
+ noValidate: true
124
+ }, Object.keys(initialValues).map(fieldName => /*#__PURE__*/React.createElement("div", {
125
+ key: fieldName,
126
+ className: "field-group"
127
+ }, /*#__PURE__*/React.createElement("label", {
128
+ htmlFor: fieldName
129
+ }, fieldLabels[fieldName]), /*#__PURE__*/React.createElement("input", {
130
+ id: fieldName,
131
+ name: fieldName,
132
+ type: fieldName === 'dateNaissance' ? 'date' : 'text',
133
+ value: values[fieldName],
134
+ onChange: onChange,
135
+ onBlur: onBlur,
136
+ "aria-invalid": Boolean(errors[fieldName]),
137
+ "aria-describedby": `${fieldName}-error`,
138
+ "data-cy": fieldName
139
+ }), shouldShowError(fieldName) && errors[fieldName] ? /*#__PURE__*/React.createElement("p", {
140
+ id: `${fieldName}-error`,
141
+ role: "alert",
142
+ className: "error-text"
143
+ }, errors[fieldName]) : null)), /*#__PURE__*/React.createElement("button", {
144
+ type: "submit",
145
+ disabled: !isValid,
146
+ className: `submit-button ${!isValid ? 'disabled' : ''}`,
147
+ "data-cy": "submit"
148
+ }, "Soumettre"), /*#__PURE__*/React.createElement(Link, {
149
+ to: "/",
150
+ "data-cy": "go-home",
151
+ className: "action-button secondary-action link-button"
152
+ }, "Retour \xE0 l'accueil")));
153
+ }
154
+ function App() {
155
+ const [users, setUsers] = useState([]);
156
+ const [toastMessage, setToastMessage] = useState('');
157
+ useEffect(() => {
158
+ let isMounted = true;
159
+ const loadRegistrations = async () => {
160
+ try {
161
+ const loadedUsers = await getRegistrations();
162
+ if (isMounted) {
163
+ setUsers(loadedUsers);
164
+ }
165
+ } catch {
166
+ if (isMounted) {
167
+ setUsers([]);
168
+ }
169
+ }
170
+ };
171
+ loadRegistrations();
172
+ return () => {
173
+ isMounted = false;
174
+ };
175
+ }, []);
176
+ const onRegister = async newUser => {
177
+ try {
178
+ await createRegistration(newUser);
179
+ setUsers(previousUsers => [...previousUsers, newUser]);
180
+ setToastMessage('Inscription enregistrée');
181
+ return true;
182
+ } catch (error) {
183
+ const statusCode = error?.response?.status;
184
+ const backendMessage = error?.response?.data?.message;
185
+ if (typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500 && backendMessage) {
186
+ setToastMessage(backendMessage);
187
+ return false;
188
+ }
189
+ if (typeof statusCode === 'number' && statusCode >= 500) {
190
+ setToastMessage('Serveur indisponible, veuillez réessayer plus tard.');
191
+ return false;
192
+ }
193
+ setToastMessage("Erreur lors de l'inscription");
194
+ return false;
195
+ }
196
+ };
197
+ return /*#__PURE__*/React.createElement(BrowserRouter, {
198
+ basename: process.env.PUBLIC_URL,
199
+ future: {
200
+ v7_startTransition: true,
201
+ v7_relativeSplatPath: true
202
+ }
203
+ }, /*#__PURE__*/React.createElement("div", {
204
+ className: "App"
205
+ }, /*#__PURE__*/React.createElement("main", {
206
+ className: "form-container"
207
+ }, /*#__PURE__*/React.createElement(Routes, null, /*#__PURE__*/React.createElement(Route, {
208
+ path: "/",
209
+ element: /*#__PURE__*/React.createElement(HomePage, {
210
+ users: users,
211
+ onStartRegistration: () => setToastMessage('')
212
+ })
213
+ }), /*#__PURE__*/React.createElement(Route, {
214
+ path: "/register",
215
+ element: /*#__PURE__*/React.createElement(RegisterPage, {
216
+ users: users,
217
+ onRegister: onRegister
218
+ })
219
+ }), /*#__PURE__*/React.createElement(Route, {
220
+ path: "*",
221
+ element: /*#__PURE__*/React.createElement(Navigate, {
222
+ to: "/",
223
+ replace: true
224
+ })
225
+ })), toastMessage ? /*#__PURE__*/React.createElement("div", {
226
+ className: "toast",
227
+ role: "status",
228
+ "aria-live": "polite",
229
+ "data-cy": "success"
230
+ }, toastMessage) : null)));
231
+ }
232
+ export default App;