├── .gitignore
├── README.md
├── app.json
├── app
├── (tabs)
│ ├── _layout.tsx
│ ├── exercices.tsx
│ └── index.tsx
├── _layout.tsx
└── exercices
│ ├── create-exercice.tsx
│ └── update
│ └── [id].tsx
├── assets
├── fonts
│ └── SpaceMono-Regular.ttf
└── images
│ └── logo.png
├── babel.config.js
├── doc
├── clean-archi.png
├── react-redux-clean-eda.png
└── redux-message-bus.png
├── jest-config.js
├── package-lock.json
├── package.json
├── src
├── exercice
│ ├── features
│ │ ├── create-exercice
│ │ │ ├── create-exercice-validator.service.ts
│ │ │ ├── create-exercice.events.ts
│ │ │ ├── create-exercice.reducer.ts
│ │ │ ├── create-exercice.state-machine.md
│ │ │ ├── create-exercice.state.model.ts
│ │ │ ├── create-exercice.use-case.spec.ts
│ │ │ └── create-exercice.use-case.ts
│ │ ├── delete-exercice
│ │ │ ├── delete-exercice.events.ts
│ │ │ ├── delete-exercice.reducer.ts
│ │ │ ├── delete-exercice.state-machine.md
│ │ │ ├── delete-exercice.state.model.ts
│ │ │ ├── delete-exercice.use-case.spec.ts
│ │ │ └── delete-exercice.use-case.ts
│ │ ├── get-exercice-by-id
│ │ │ ├── get-exercice-by-id.events.ts
│ │ │ ├── get-exercice-by-id.reducer.ts
│ │ │ ├── get-exercice-by-id.state-machine.md
│ │ │ ├── get-exercice-by-id.state.model.ts
│ │ │ ├── get-exercice-by-id.usecase.spec.ts
│ │ │ └── get-exercice-by-id.usecase.ts
│ │ ├── list-exercices
│ │ │ ├── list-exercices-sort.enum.ts
│ │ │ ├── list-exercices.events.ts
│ │ │ ├── list-exercices.reducer.ts
│ │ │ ├── list-exercices.state-machine.md
│ │ │ ├── list-exercices.state.model.ts
│ │ │ ├── list-exercices.use-case.spec.ts
│ │ │ └── list-exercices.use-case.ts
│ │ ├── shared
│ │ │ ├── exercice.reducer.ts
│ │ │ ├── exercice.repository.interface.ts
│ │ │ ├── exercice.state.model.ts
│ │ │ ├── infrastructure
│ │ │ │ └── exercice.repository.in-memory.ts
│ │ │ └── test
│ │ │ │ ├── exercice-error.repository.fake.ts
│ │ │ │ ├── exercice-loading.repository.fake.ts
│ │ │ │ ├── exercice-success.repository.fake.ts
│ │ │ │ └── utils
│ │ │ │ └── create-test-store-with-exercices.ts
│ │ └── update-exercice
│ │ │ ├── update-exercice.events.ts
│ │ │ ├── update-exercice.reducer.ts
│ │ │ ├── update-exercice.state-machine.md
│ │ │ ├── update-exercice.state.model.ts
│ │ │ ├── update-exercice.usecase.spec.ts
│ │ │ └── update-exercice.usecase.ts
│ └── ui
│ │ ├── create-exercice
│ │ ├── CreateExercice.tsx
│ │ └── create-exercice.view-model.ts
│ │ ├── list-all-exercices
│ │ ├── ListAllExercices.tsx
│ │ ├── ListAllExercices.view-model.ts
│ │ └── display-exercice
│ │ │ ├── DisplayExercice.tsx
│ │ │ └── exercice-actions
│ │ │ ├── ExerciceActions.tsx
│ │ │ └── exercice-actions.view-model.ts
│ │ └── update-exercice
│ │ ├── UpdateExercice.tsx
│ │ └── update-exercice.view-model.ts
├── muscle
│ ├── features
│ │ └── shared
│ │ │ ├── muscle.model.type.ts
│ │ │ └── muscle.repository.interface.ts
│ └── shared
│ │ └── infrastructure
│ │ └── muscle.repository.in-memory.ts
├── notification
│ ├── features
│ │ ├── add-notification
│ │ │ ├── add-notification.reducer.ts
│ │ │ └── notification-generator.service.ts
│ │ ├── remove-notification
│ │ │ ├── remove-notification.events.ts
│ │ │ ├── remove-notification.reducer.ts
│ │ │ ├── remove-notification.state-machine.md
│ │ │ ├── remove-notification.use-case.spec.ts
│ │ │ └── remove-notification.use-case.ts
│ │ └── shared
│ │ │ ├── notification.reducer.ts
│ │ │ └── notification.state.model.ts
│ └── ui
│ │ └── component
│ │ ├── Notifications.tsx
│ │ └── Notifications.view-model.ts
└── shared
│ ├── application
│ ├── compose-reducers.service.ts
│ ├── root.reducer.ts
│ ├── root.state.ts
│ ├── root.store.ts
│ ├── test
│ │ └── test.store.ts
│ └── thunk.type.ts
│ └── ui
│ └── component
│ ├── home
│ └── Home.tsx
│ └── layout
│ └── ScreenLayout.tsx
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 |
13 | # macOS
14 | .DS_Store
15 |
16 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
17 | # The following patterns were generated by expo-cli
18 |
19 | expo-env.d.ts
20 | # @end expo-cli
21 | # Local Netlify folder
22 | .netlify
23 |
24 | .idea
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | The purpose [of this repository](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices) is to build a front end application with react, using the Clean Architecture, Event Driven Architecture, TDD and vertical slices principles.
3 |
4 | This architecture have been showcased in the "[CTO InShape](https://www.youtube.com/channel/UCfRCkaIb9gEYVeFOsmYsylQ)" podcast during a pair programming session with [Mathieu Kahlaoui](https://www.linkedin.com/in/mathieu-kahlaoui/)! You can watch the podcast here: [CTO In Shape - Clean Architecture with Redux](https://www.youtube.com/watch?v=xA_ZL926tgY).
5 |
6 |
7 | ## Table of Contents
8 |
9 | 1. [Introduction](#intro)
10 | 2. [Issues with React and "classic" state management](#issues-react)
11 | 3. [Stack](#stack)
12 | 4. [Installation](#install)
13 | 5. [Clean architecture in front-end](#clean-archi)
14 | 6. [Redux](#redux)
15 | 7. [Dev methodology with TDD](#methodo)
16 | 8. [Execution flow](#execution-flow)
17 | 9. [DDD ?](#ddd)
18 | 10. [Vertical slices](#vertical-slices)
19 | 11. [Useful Ressources](#ressources)
20 |
21 |
22 |
23 | ## 1. Introduction
24 |
25 | This project is exploratory and aims to experiment with different ways to structure a front end application and use of TDD. It's my way of doing it and it can surely be better, so feel free to contribute, provide feedback or start PR if you have any ideas, suggestions, or believe something can be improved!
26 |
27 | The React components and React Native screens **are not** the main focus of this project and have been kept simple. So you may still encounter some TODO items or TypeScript warnings / errors (even some "any", oops) in the UI layer. Also there is not test for react component.
28 |
29 |
30 |
31 |
32 |
33 | ## My issues with react without clean architecture and with "classic" react state management:
34 |
35 | - Feeling like I'm hacking things together to manage my components' state
36 | - Struggling to decouple enough from the UI
37 | - Creating/moving hooks and "services" here and there
38 | - Difficulty to reason about the state and the transitions between states and get the big picture
39 | - Overuse of hooks
40 | - Having part of the business logic in the UI layer
41 | - Failing to do TDD or test properly (too much coupling, too fragile, little logic to test...) and struggling to give value to those tests
42 | - Switching from one state management library to another
43 | - Trying to implement principles that don't fit well with React’s flow, ending up with overengineered solutions
44 | - Having to reload the page to see state changes and replay scenarios
45 | - Needing a backend to test scenarios and polluting the database with every manual test
46 |
47 | ## Stack
48 |
49 | | JS | TS | React | Redux | React Native | Jest |
50 | |---|------|----------------------------------------------------------------|----------------------------------------------------------------|-----------------------------------------------------------------------|----------------------------------------------------------------|
51 | |
|
|
|
|
|
|
52 |
53 |
54 |
55 | ## Installation
56 |
57 | 1. Fork this repository
58 | 2. Clone your forked repository
59 | 3. Install dependencies
60 |
61 | ```bash
62 | npm install
63 | ```
64 |
65 | 4. Run the app with expo
66 |
67 | ```bash
68 | npm run start
69 | ```
70 |
71 | 5. Run the unit tests
72 |
73 | ```bash
74 | npm run test
75 | ```
76 |
77 |
78 | ## Clean architecture in frontend:
79 |
80 | Clean architecture is often associated with back-end development. Using it on the front end is sometimes seen as overkill, because for a lot of people the frontend is easy. Maybe it was when SSR was the norm. But in a lot of projects using a separate frontend Single Page App (react, View, Angular etc), it's not the case anymore.
81 |
82 | And implementing Clean Architecture is not necessary complex, and it can be very, very useful, event in frontend apps.
83 |
84 | The main idea here is to separate the React component from the business logic and the data access. This way, we can develop and test the business logic without needing to open the browser or having to wait for a backend.
85 |
86 | Imagine this user story: “**As a user, I want to create an exercise**.”
87 |
88 | The business logic is to create an exercise. The data access involves sending a fetch request to create an exercises. The React component is responsible for displaying an form and allowing the user to create a new exercise.
89 |
90 |
91 | So we can already separate the code into three parts:
92 |
93 | - The React component (UI)
94 | - The business logic (use case)
95 | - The data access (repository)
96 |
97 | We can create a class or function called [CreateExerciseUseCase](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.use-case.ts) that validates the data sent by the user and creates the exercise by calling the repository.
98 | The repository contains the fetch function to actually create the exercise in the backend.
99 |
100 | In practice, the UI calls the use case, and the use case calls the repository. Because the use case is coupled to the repository, it cannot be easily tested without sending a request to the backend.
101 |
102 | To solve this, we can apply the Dependency Inversion Principle (DIP) and inject the repository into the use case via an interface ([here](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.use-case.ts)). This way, we can mock the repository: either for developping the frontend without having a backend yet with a [in-memory implementation](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/shared/infrastructure/exercice.repository.in-memory.ts) (useful for rapid Proof of Value), or in tests with [fakes](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/shared/test/exercice-error.repository.fake.ts), allowing us to test the use case without actually making real requests.
103 |
104 | Annnnd that’s it. We have a Clean Architecture approach in the frontend as the dependencies go inward.
105 |
106 | 
107 | [_(image from Milan Jovanović blog)_](https://www.milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design)
108 |
109 | Please note that the layers here are not defined with traditional folders like application, domain, infrastructure, etc., but Clean Architecture principles are still respected. A vertical slices approach is used instead to gather nearly all the code related to a feature in one place ([here](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/tree/main/src/exercice/features/create-exercice)) and it's not contradictory to the Clean Architecture. As Robert C. Martin says: “_The architecture should scream the use cases of the application._” (Robert C. Martin).
110 |
111 | But as the state was managed by React, it's still difficult to test the state changes and the transitions between states.
112 | That's when Redux comes in.
113 |
114 |
115 |
116 | ### What about Redux?
117 |
118 | To store data in our application (e.g., the logged-in user, a list of exercises, etc.), we need state. To share this state between components, we can use the Context API or a state management library such as Zustand, Recoil, or Redux. Redux has a reputation for having a lot of boilerplate and is sometimes considered overkill.
119 |
120 | So why choose Redux?
121 | Because Redux is not just a state manager. In this app, we use Redux and RTK for four main purposes:
122 |
123 | 1) State Management: to store the global application state.
124 | 2) (Kind of) Pub/Sub System / Synchronous event bus: In Redux we dispatch actions. This actions are "listened" by reducers in order to update the state accordingly. This part of Redux can be seen as a synchronous event bus. So in this repository, the actions dispatched by the use case are named "event" ([here](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.events.ts)), because they feel more like it (in a event driven architecture style).
125 |
126 | 
127 | [_image from Yazan Alaboudi Redux talk_](https://slides.com/yazanalaboudi/deck#/46)
128 |
129 | 3) Dependency Injection: By using the extraArgument option in the Redux store creation (using RTK configure store, [here](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/shared/application/root.store.ts)), we can inject the repository (for data fetching, etc.) or any other infrastucture dependency into the use case ([here](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.use-case.spec.ts)).
130 | 4) Middleware for Side Effects: Redux Thunk handles side effects such as API calls. Therefore, our use cases are implemented as Redux thunks, providing more granular control over how Redux actions (events) are dispatched related to the side effects ([here](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.use-case.ts)).
131 |
132 |
133 | 
134 |
135 | **It's important to notice that Redux lives in our domain / application layers. It's not considered as an infrastructure tool, but as a core part of our application. It's a pragmatic choice for reducing the complexity.**
136 |
137 | I feel like Redux perfectly fills the missing holes with Clean Architecture with React.
138 | The event driven architecture which is enabled by Redux allows us to manage the state in a predictable way. And to think about state and transition without React in mind. That way, we can focus on the business logic and the state transitions, and test them without needing to open the browser. We can also modelize the state transitions with a state machine diagram, which is a great way to visualize the application flow, and using TDD to develop the use cases and state changes.
139 | When done, we just need to plug in the React component.
140 |
141 | **So React is used only for what it was designed for: the UI.**
142 |
143 |
144 | Please note that:
145 | - If other events related to another feature need to be dispatched after an event (e.g., creating an exercise triggers a new fetch of exercises), they are dispatched within the same use case. Because a use case does not calls another use case. And React component should not manage the application flow: it's not his responsibility call a use case after one is done.
146 | - The state in this project is not normalized (using Normalizer for exemple) and the ui state is not separated from the "entity" state in the store (but it can be if the relational / nested data become is too complex)
147 | - The selectors in this project are not created using createSelector (Reselect) because the data retrieved from the store is not derived or transformed
148 |
149 |
150 |
151 |
152 |
153 | ### My Dev methodology using TDD:
154 | 1. Definition of the user story and scenarios for the feature. Example:
155 | - As a user, I want to create an exercise
156 | - Given no exercise is already created
157 | - When the exercise creation starts
158 | - Then the loading should be true
159 | 2. Creation of a state machine diagram to visualize state transitions (directly in webstorm using [Mermaid](https://mermaid-js.github.io/mermaid/#/)):
160 |
161 | ```mermaid
162 | ---
163 | title: Create Exercice State
164 | ---
165 |
166 | flowchart TD
167 | A[
168 | Idle
169 |
170 | Status: idle
171 | Error: null
172 |
173 | Notifications: n
174 |
175 | List Exercices Data: n
176 | ]
177 |
178 | B[
179 | Loading
180 |
181 | Status: loading
182 | Error: null
183 |
184 | Notifications: n
185 |
186 | List Exercices Data: n
187 | ]
188 |
189 | C[
190 | Error
191 |
192 | Status: error
193 | Error: error message
194 |
195 | Notification: n + 1 error
196 |
197 | List Exercices Data: n
198 | ]
199 |
200 | D[
201 | Success
202 |
203 | Status: success
204 | Error: null
205 |
206 | Notification: n + 1 success
207 | ]
208 |
209 |
210 | E[
211 | List exercices Success
212 |
213 | ...
214 | Data: n + created exercice
215 | ]
216 |
217 | subgraph Create Exercice
218 | A -->|Exercice creation Started|B
219 | B -->|Exercice creation failed|C
220 | B -->|Exercice Created|D
221 | end
222 |
223 | subgraph List exercices
224 | D -->|...|E
225 | end
226 |
227 | ```
228 | 3. Writing the first acceptance test based the scenario. ([here](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.use-case.spec.ts)): red
229 | 4. Implementation of the use case using baby steps (green)
230 | 5. Refactoring the code (refactor)
231 | 6. Writing a second acceptance test
232 |
233 | The unit tests here are usually socials (and can serve as specifications) in order to avoid fragile tests because of "behavior sensitivity" : test should change if the scenario change, not if the inner code change (check [the awesome Ian Cooper's "TDD Revisited" talk](https://www.youtube.com/watch?v=IN9lftH0cJc)).
234 |
235 | In this repo, the use case is the starting point and it asserts against the current state with selectors.
236 |
237 |
238 | ### Execution flow:
239 |
240 | ```mermaid
241 | ---
242 | title: clean archi + redux flow
243 | ---
244 |
245 | sequenceDiagram
246 | autonumber
247 |
248 | box rgb(211,69,92) UI
249 | participant REACT COMPONENT
250 | end
251 |
252 | box rgb(32,120,103) APPLICATION
253 | participant USE CASE (THUNK)
254 | participant EVENT
255 | participant REDUCER
256 | end
257 |
258 |
259 | box rgb(65,144,213) DOMAIN
260 | participant STATE MODEL
261 | participant SELECTOR
262 | end
263 |
264 | box rgb(211,69,92) INFRASTRUCTURE
265 | participant REPOSITORY
266 | end
267 |
268 | REACT COMPONENT->>USE CASE (THUNK): calls with dispatch on user action / mount
269 |
270 | USE CASE (THUNK)-->>REPOSITORY: fetch data (via DIP)
271 |
272 | USE CASE (THUNK)->>EVENT: dispatch
273 | REDUCER->>EVENT: listen
274 | REDUCER->>STATE MODEL: update (via copy)
275 | SELECTOR-->>STATE MODEL: read state
276 | REACT COMPONENT-->>SELECTOR: get state and subscribed to changes (via useSelector )
277 |
278 | REACT COMPONENT->>REACT COMPONENT: re-render
279 |
280 |
281 | %%{
282 | init: {
283 | 'theme': 'base',
284 | 'themeVariables': {
285 | 'background': 'white',
286 | 'primaryColor': 'white',
287 | 'primaryTextColor': 'black',
288 | 'primaryBorderColor': 'lightgrey',
289 | 'lineColor': 'green',
290 | 'secondaryColor': 'green',
291 | 'tertiaryColor': 'green'
292 | }
293 | }
294 | }%%
295 |
296 | ```
297 |
298 |
299 |
300 | ### Execution full flow exemple for create exercice use case (yeah it looks scary but it's not that bad):
301 |
302 | ```mermaid
303 | ---
304 | title: clean archi + redux flow (create exercice)
305 | ---
306 |
307 | sequenceDiagram
308 | autonumber
309 |
310 | box rgb(211,69,92) UI
311 | participant REACT COMPONENT CREATE EXERCICE
312 | participant REACT COMPONENT NOTIFICATIONS
313 | end
314 |
315 | box rgb(32,120,103) APPLICATION
316 | participant USE CASE (THUNK)
317 | participant EVENT
318 | participant REDUCER CREATE EXERCICE
319 | participant REDUCER CREATE NOTIFICATION
320 | end
321 |
322 | box rgb(65,144,213) DOMAIN
323 | participant STATE MODEL EXERCICE
324 | participant STATE MODEL NOTIFICATION
325 | participant EXERCICE SELECTOR
326 | participant NOTIFICATION SELECTOR
327 |
328 | end
329 |
330 | box rgb(211,69,92) INFRASTRUCTURE
331 | participant REPOSITORY
332 | end
333 |
334 |
335 |
336 |
337 | REACT COMPONENT CREATE EXERCICE->>USE CASE (THUNK): submit exercice form
338 | USE CASE (THUNK)->>EVENT: dispatch "exercice creation started" event
339 |
340 | REDUCER CREATE EXERCICE->>EVENT: listen to "exercice creation started" event
341 | REDUCER CREATE EXERCICE->>STATE MODEL EXERCICE: update create exercice state (via copy) with loading: true
342 | EXERCICE SELECTOR-->>STATE MODEL EXERCICE: read state
343 | REACT COMPONENT CREATE EXERCICE-->>EXERCICE SELECTOR: subscribed to changes in state(via useSelector)
344 | REACT COMPONENT CREATE EXERCICE->>REACT COMPONENT CREATE EXERCICE: re-render to show loading spinner
345 |
346 | USE CASE (THUNK)-->>REPOSITORY: fetch create exercice (via DIP) and return success
347 | USE CASE (THUNK)->>EVENT: dispatch exerciceCreated
348 |
349 | REDUCER CREATE EXERCICE->>EVENT: listen to "exercice created" event
350 | REDUCER CREATE EXERCICE->>STATE MODEL EXERCICE: update create exercice state with loading: false
351 | EXERCICE SELECTOR-->>STATE MODEL EXERCICE: read state
352 | REACT COMPONENT CREATE EXERCICE-->>EXERCICE SELECTOR: get state and subscribed to changes (via useSelector )
353 | REACT COMPONENT CREATE EXERCICE->>REACT COMPONENT CREATE EXERCICE: re-render to hide loading spinner
354 |
355 | REDUCER CREATE NOTIFICATION->>EVENT: listen to "exercice created" event
356 | REDUCER CREATE NOTIFICATION->>STATE MODEL EXERCICE: update notification state with a new success message
357 | NOTIFICATION SELECTOR-->>STATE MODEL NOTIFICATION: read state
358 | REACT COMPONENT NOTIFICATIONS-->>NOTIFICATION SELECTOR: subscribed to changes in state (via useSelector)
359 | REACT COMPONENT NOTIFICATIONS->REACT COMPONENT NOTIFICATIONS: re-render to remove loading spinner and show success notification (batch)
360 |
361 |
362 | %%{
363 | init: {
364 | 'theme': 'base',
365 | 'themeVariables': {
366 | 'background': 'white',
367 | 'primaryColor': 'white',
368 | 'primaryTextColor': 'black',
369 | 'primaryBorderColor': 'lightgrey',
370 | 'lineColor': 'green',
371 | 'secondaryColor': 'green',
372 | 'tertiaryColor': 'green'
373 | }
374 | }
375 | }%%
376 | ```
377 |
378 |
379 |
380 |
381 |
382 |
383 | ### ~~DDD~~:
384 |
385 | I don't use the DDD tactical patterns in the front end, as business rules and invariant guarantees are handled by the backend (single source of truth). So i don't have entities, aggregates, value objects, etc. For validations is use simple validation services ([here](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice-validator.service.ts)) called within the use cases (or a validation lib in the react component).
386 |
387 |
388 |
389 | ### About vertical slices:
390 |
391 | Each feature contains:
392 | - [The anemic state shape and the initial state](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.state.model.ts) (domain model)
393 | - [The selectors](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.state.model.ts) (to read the state from the store, like "getters")
394 | - [The reducers](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.reducer.ts) (to listens to events and to update the state)
395 | - [The use case](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.use-case.ts) (to call the repository and dispatch events)
396 | - [The Use case tests](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.use-case.spec.ts)
397 | - [The State transition diagram](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.state-machine.md) (state machine diagram)
398 | - [The events](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice.events.ts) (Redux actions created with createAction)
399 | - [A service validator](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/create-exercice/create-exercice-validator.service.ts), if necessary (invariant is mostly handled by the backend)
400 |
401 | Some files are shared between features:
402 | - The repository: [implementation](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/shared/infrastructure/exercice.repository.in-memory.ts) + [interface](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/shared/exercice.repository.interface.ts). I find it overkill to create a repository + repository interface per use case, manage its injection, etc, like it's sometimes done in Vertical Slices.
403 | - [The domain model type](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/shared/exercice.state.model.ts)
404 | - [A reducer that combines each feature's reducers](https://github.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/blob/main/src/exercice/features/shared/exercice.reducer.ts)
405 | - Using combineReducer if each reducer operates on a separate portion of the state
406 | - OR using a custom utility composeReducers to merge reducers without creating a new state key if reducers operate on the same state portion (e.g., creating/deleting notifications)
407 |
408 |
409 |
410 |
411 | ## Useful ressources:
412 |
413 | - [Codeminer42 Blog "Scalable Frontend series"](https://blog.codeminer42.com/scalable-frontend-1-architecture-9b80a16b8ec7/)
414 | - [Michel Weststrate's "UI as an afterthought" article](https://michel.codes/blogs/ui-as-an-afterthought)
415 | - [Dan Abramov's "Hot Reloading with Time Travel" talk](https://www.youtube.com/watch?v=xsSnOQynTHs)
416 | - [Dan Abramov's "The Redux Journey " talk](https://www.youtube.com/watch?v=uvAXVMwHJXU)
417 | - [Michaël Azerhad's Linkedin posts about Redux](https://www.linkedin.com/in/michael-azerhad/)
418 | - [Lee Byron's "Immutable Application Architecture" talk](https://www.youtube.com/watch?v=oTcDmnAXZ4E)
419 | - [Nir Kaufman's "Advanced Redux Patterns" talk](https://www.youtube.com/watch?v=JUuic7mEs-s)
420 | - [Robin Wieruch's book "Taming state in react"](https://github.com/taming-the-state-in-react/taming-the-state-in-react?tab=readme-ov-file)
421 | - [Facebook Flux presentation](https://www.youtube.com/watch?v=nYkdrAPrdcw&list=PLb0IAmt7-GS188xDYE-u1ShQmFFGbrk0v)
422 | - [Yazan Alaboudi's "Our Redux Anti Pattern" talk](https://slides.com/yazanalaboudi/deck#/46)
423 | - [Robert C. Martin's "Clean Architecture" book](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
424 | - [David Khourshid's "Robust React User Interfaces with Finite State Machines" article](http://css-tricks.com/robust-react-user-interfaces-with-finite-state-machines/)
425 | - [David Khourshid's "Infinitely Better UIs with Finite Automata" talk](https://www.youtube.com/watch?v=VU1NKX6Qkxc)
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "train-better-app",
4 | "slug": "train-better-app",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "automatic",
10 | "splash": {
11 | "image": "./assets/images/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "ios": {
16 | "supportsTablet": true
17 | },
18 | "android": {
19 | "adaptiveIcon": {
20 | "foregroundImage": "./assets/images/adaptive-icon.png",
21 | "backgroundColor": "#ffffff"
22 | }
23 | },
24 | "web": {
25 | "bundler": "metro",
26 | "output": "static"
27 | },
28 | "plugins": ["expo-router"],
29 | "experiments": {
30 | "typedRoutes": true
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from "expo-router";
2 | import { Ionicons } from "@expo/vector-icons";
3 |
4 | export default function TabsLayout() {
5 | return (
6 |
19 | (
24 |
25 | ),
26 | }}
27 | />
28 | (
33 |
34 | ),
35 | }}
36 | />
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/app/(tabs)/exercices.tsx:
--------------------------------------------------------------------------------
1 | import ListAllExercices from "@/src/exercice/ui/list-all-exercices/ListAllExercices";
2 | import ScreenLayout from "@/src/shared/ui/component/layout/ScreenLayout";
3 |
4 | export default function ExercicesScreen() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/app/(tabs)/index.tsx:
--------------------------------------------------------------------------------
1 | import Home from "@/src/shared/ui/component/home/Home";
2 | import ScreenLayout from "@/src/shared/ui/component/layout/ScreenLayout";
3 |
4 | export default function HomeScreen() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import store from "@/src/shared/application/root.store";
2 | import { Stack } from "expo-router";
3 | import { GestureHandlerRootView } from "react-native-gesture-handler";
4 | import { Provider } from "react-redux";
5 |
6 | export default function RootLayout() {
7 | return (
8 |
9 |
10 |
11 |
12 |
23 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/app/exercices/create-exercice.tsx:
--------------------------------------------------------------------------------
1 | import CreateExercice from "@/src/exercice/ui/create-exercice/CreateExercice";
2 | import ScreenLayout from "@/src/shared/ui/component/layout/ScreenLayout";
3 |
4 | export default function CreateExerciceScreen() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/app/exercices/update/[id].tsx:
--------------------------------------------------------------------------------
1 | import UpdateExercice from "@/src/exercice/ui/update-exercice/UpdateExercice";
2 | import ScreenLayout from "@/src/shared/ui/component/layout/ScreenLayout";
3 |
4 | const UpdateExerciceScreen: React.FC = () => {
5 | return (
6 |
7 |
8 |
9 | );
10 | };
11 |
12 | export default UpdateExerciceScreen;
13 |
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/0077b74de322fc4c09d1fa52c843e6afa3a2f7f2/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/0077b74de322fc4c09d1fa52c843e6afa3a2f7f2/assets/images/logo.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ["babel-preset-expo"],
5 | plugins: ["react-native-reanimated/plugin"],
6 | };
7 | };
8 |
--------------------------------------------------------------------------------
/doc/clean-archi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/0077b74de322fc4c09d1fa52c843e6afa3a2f7f2/doc/clean-archi.png
--------------------------------------------------------------------------------
/doc/react-redux-clean-eda.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/0077b74de322fc4c09d1fa52c843e6afa3a2f7f2/doc/react-redux-clean-eda.png
--------------------------------------------------------------------------------
/doc/redux-message-bus.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidroberto/react-redux-clean-architecture-tdd-eda-vertical-slices/0077b74de322fc4c09d1fa52c843e6afa3a2f7f2/doc/redux-message-bus.png
--------------------------------------------------------------------------------
/jest-config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: "jest-expo",
3 | moduleNameMapper: {"^uuid$": "uuid"},
4 |
5 | };
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "train-better-app",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "reset-project": "node ./scripts/reset-project.js",
8 | "android": "expo start --android",
9 | "ios": "expo start --ios",
10 | "web": "expo start --web",
11 | "test": "jest --config ./jest-config.js --watch",
12 | "lint": "expo lint"
13 | },
14 | "dependencies": {
15 | "@expo/vector-icons": "^14.0.3",
16 | "@react-navigation/native": "^6.0.2",
17 | "@reduxjs/toolkit": "^2.5.0",
18 | "expo": "~51.0.28",
19 | "expo-constants": "~16.0.2",
20 | "expo-font": "~12.0.9",
21 | "expo-image-picker": "~15.0.7",
22 | "expo-linking": "~6.3.1",
23 | "expo-router": "~3.5.23",
24 | "expo-splash-screen": "~0.27.5",
25 | "expo-status-bar": "~1.12.1",
26 | "expo-system-ui": "~3.0.7",
27 | "expo-web-browser": "~13.0.3",
28 | "react": "18.2.0",
29 | "react-dom": "18.2.0",
30 | "react-native": "0.74.5",
31 | "react-native-gesture-handler": "^2.16.1",
32 | "react-native-reanimated": "^3.4.0",
33 | "react-native-safe-area-context": "4.10.5",
34 | "react-native-screens": "3.31.1",
35 | "react-native-web": "~0.19.10",
36 | "react-redux": "^9.2.0",
37 | "uuid": "^10.0.0"
38 | },
39 | "devDependencies": {
40 | "@babel/core": "^7.20.0",
41 | "@types/jest": "^29.5.12",
42 | "@types/react": "~18.2.45",
43 | "@types/react-test-renderer": "^18.0.7",
44 | "@types/uuid": "^10.0.0",
45 | "jest": "^29.2.1",
46 | "jest-expo": "~51.0.3",
47 | "react-test-renderer": "18.2.0",
48 | "ts-jest": "^29.2.5",
49 | "typescript": "~5.3.3"
50 | },
51 | "private": true
52 | }
53 |
--------------------------------------------------------------------------------
/src/exercice/features/create-exercice/create-exercice-validator.service.ts:
--------------------------------------------------------------------------------
1 | import {CreateExerciceCommand} from "@/src/exercice/features/create-exercice/create-exercice.use-case";
2 |
3 | export const validateExerciceService = (createExercice: CreateExerciceCommand): string[] => {
4 | const MIN_TITLE_LENGTH = 2;
5 |
6 | const errors: string[] = [];
7 |
8 | if (createExercice.title.length < MIN_TITLE_LENGTH) {
9 | errors.push("titre trop court");
10 | }
11 |
12 | return errors;
13 | }
--------------------------------------------------------------------------------
/src/exercice/features/create-exercice/create-exercice.events.ts:
--------------------------------------------------------------------------------
1 | import {createAction} from "@reduxjs/toolkit";
2 |
3 | export const exerciceCreationStarted = createAction("EXERCICE_CREATION_STARTED",);
4 | export const exerciceCreated = createAction("EXERCICE_CREATED");
5 | export const exerciceCreationFailed = createAction("EXERCICE_CREATION_FAILED", (errorMessage: string) => ({
6 | payload: errorMessage,
7 | }));
8 |
--------------------------------------------------------------------------------
/src/exercice/features/create-exercice/create-exercice.reducer.ts:
--------------------------------------------------------------------------------
1 | import {createReducer} from "@reduxjs/toolkit";
2 | import {
3 | exerciceCreated, exerciceCreationFailed, exerciceCreationStarted
4 | } from "@/src/exercice/features/create-exercice/create-exercice.events";
5 | import {
6 | createExerciceInitialState, CreateExerciceStatus
7 | } from "@/src/exercice/features/create-exercice/create-exercice.state.model";
8 |
9 | const createExerciceReducer = createReducer(createExerciceInitialState, (builder) => {
10 | builder
11 | .addCase(exerciceCreationStarted, (state) => {
12 | state.status = CreateExerciceStatus.LOADING;
13 | })
14 |
15 | .addCase(exerciceCreated, (state) => {
16 | state.status = CreateExerciceStatus.SUCCESS;
17 | })
18 |
19 | .addCase(exerciceCreationFailed, (state, action) => {
20 | state.status = CreateExerciceStatus.ERROR;
21 | state.error = action.payload;
22 | })
23 |
24 | });
25 |
26 | export default createExerciceReducer;
27 |
--------------------------------------------------------------------------------
/src/exercice/features/create-exercice/create-exercice.state-machine.md:
--------------------------------------------------------------------------------
1 | ```mermaid
2 | ---
3 | title: Create Exercice State
4 | ---
5 |
6 | flowchart TD
7 | A[
8 | Idle
9 |
10 | Status: idle
11 | Error: null
12 |
13 | Notifications: n
14 |
15 | List Exercices Data: n
16 | ]
17 |
18 | B[
19 | Loading
20 |
21 | Status: loading
22 | Error: null
23 |
24 | Notifications: n
25 |
26 | List Exercices Data: n
27 | ]
28 |
29 | C[
30 | Error
31 |
32 | Status: error
33 | Error: error message
34 |
35 | Notification: n + 1 error
36 |
37 | List Exercices Data: n
38 | ]
39 |
40 | D[
41 | Success
42 |
43 | Status: success
44 | Error: null
45 |
46 | Notification: n + 1 success
47 | ]
48 |
49 |
50 | E[
51 | List exercices Success
52 |
53 | ...
54 | Data: n + created exercice
55 | ]
56 |
57 | subgraph Create Exercice
58 | A -->|Exercice creation Started|B
59 | B -->|Exercice creation failed|C
60 | B -->|Exercice Created|D
61 | end
62 |
63 | subgraph List exercices
64 | D -->|...|E
65 | end
66 |
67 | ```
--------------------------------------------------------------------------------
/src/exercice/features/create-exercice/create-exercice.state.model.ts:
--------------------------------------------------------------------------------
1 | import {RootState} from "@/src/shared/application/root.state";
2 |
3 | export type CreateExerciceStateModel = {
4 | error: string | null;
5 | status: CreateExerciceStatus;
6 | };
7 |
8 | export enum CreateExerciceStatus {
9 | IDLE = "idle", LOADING = "loading", SUCCESS = "success", ERROR = "error",
10 | }
11 |
12 | export const createExerciceInitialState: CreateExerciceStateModel = {
13 | error: null,
14 | status: CreateExerciceStatus.IDLE
15 | };
16 |
17 | export const getExerciceCreateStatus = (state: RootState) => {
18 | return state.exercices.create.status;
19 | }
20 |
21 | export const getExerciceCreateError = (state: RootState) => {
22 | return state.exercices.create.error;
23 | }
--------------------------------------------------------------------------------
/src/exercice/features/create-exercice/create-exercice.use-case.spec.ts:
--------------------------------------------------------------------------------
1 | import {AppStore} from "@/src/shared/application/root.store";
2 | import {ExerciceLoadingRepositoryFake} from "@/src/exercice/features/shared/test/exercice-loading.repository.fake";
3 | import {ExerciceSuccessRepositoryFake} from "@/src/exercice/features/shared/test/exercice-success.repository.fake";
4 | import {ExerciceErrorRepositoryFake} from "@/src/exercice/features/shared/test/exercice-error.repository.fake";
5 | import {createTestStore} from "@/src/shared/application/test/test.store";
6 | import {
7 | CreateExerciceCommand, createExerciceUseCase
8 | } from "@/src/exercice/features/create-exercice/create-exercice.use-case";
9 | import {
10 | getExerciceCreateError, getExerciceCreateStatus
11 | } from "@/src/exercice/features/create-exercice/create-exercice.state.model";
12 | import {getExercicesListData} from "@/src/exercice/features/list-exercices/list-exercices.state.model";
13 | import {getNotificationsList, NotificationType} from "@/src/notification/features/shared/notification.state.model";
14 |
15 | describe("As a user i want to create an exercice", () => {
16 | let testStore: AppStore;
17 | const createExerciceCommand: CreateExerciceCommand = {
18 | title: "Romanian Deadlift",
19 | description: "The Romanian deadlift is a variation of the conventional deadlift that targets the posterior chain, including the hamstrings, glutes, and lower back.",
20 | image: "https://wger.de/media/exercise-images/89/Romanian-deadlift-1.png",
21 | youtubeVideoUrl: "https://www.youtube.com/watch?v=jEy_czb3RKA",
22 | primaryMuscles: [{id: "201"}],
23 | secondaryMuscles: [{id: "202"}],
24 | };
25 |
26 | describe("Given no exercice is already created", () => {
27 | beforeAll(() => {
28 | testStore = createTestStore();
29 | });
30 |
31 | describe("When the exercice creation has not started", () => {
32 | test("Then the status should be idle", async () => {
33 | expect(getExerciceCreateStatus(testStore.getState())).toBe("idle");
34 | });
35 |
36 | test("Then there should be no error", async () => {
37 | expect(getExerciceCreateError(testStore.getState())).toBe(null);
38 | });
39 |
40 | test("Then the exercices list should be empty", async () => {
41 | expect(getExercicesListData(testStore.getState()).length).toBe(0);
42 | });
43 | });
44 | });
45 |
46 | describe("Given no exercice is already created", () => {
47 | beforeAll(() => {
48 | testStore = createTestStore();
49 | });
50 |
51 | describe("When the exercice creation starts", () => {
52 | beforeAll(() => {
53 | createExerciceUseCase(createExerciceCommand)(testStore.dispatch, testStore.getState, {
54 | exerciceRepository: new ExerciceLoadingRepositoryFake()
55 | });
56 | });
57 |
58 | test("Then the status should be loading", async () => {
59 | expect(getExerciceCreateStatus(testStore.getState())).toBe("loading");
60 | });
61 |
62 | test("Then there should be no error", async () => {
63 | expect(getExerciceCreateError(testStore.getState())).toBe(null);
64 | });
65 |
66 | test("Then the exercices list should be empty", async () => {
67 | expect(getExercicesListData(testStore.getState()).length).toBe(0);
68 | });
69 |
70 | });
71 | });
72 |
73 | describe("Given no exercice is already created", () => {
74 | beforeAll(() => {
75 | testStore = createTestStore();
76 | });
77 |
78 | describe("When the exercice is created successfully", () => {
79 | beforeAll(async () => {
80 | await createExerciceUseCase(createExerciceCommand)(testStore.dispatch, testStore.getState, {
81 | exerciceRepository: new ExerciceSuccessRepositoryFake(),
82 | },);
83 | });
84 |
85 | test("Then the status should be success", async () => {
86 | expect(getExerciceCreateStatus(testStore.getState())).toBe("success");
87 | });
88 |
89 | test("Then it should add a success notification", () => {
90 | const createSuccessNotification = getNotificationsList(testStore.getState()).find(
91 | (notification) => notification.message === "Exercice créé",);
92 |
93 | expect(createSuccessNotification).not.toBeUndefined();
94 | expect(createSuccessNotification?.type).toBe(NotificationType.SUCCESS);
95 | });
96 |
97 | test("Then the new exercice should be in the list", () => {
98 | const exercicesInStore = getExercicesListData(testStore.getState());
99 |
100 | const newExerciceInStore = exercicesInStore.find(
101 | (exercice) => exercice.title === createExerciceCommand.title,);
102 | expect(newExerciceInStore).not.toBeUndefined();
103 | });
104 | });
105 | });
106 |
107 | describe("Given no exercice is already created", () => {
108 | beforeAll(() => {
109 | testStore = createTestStore();
110 | });
111 |
112 | describe("When creating an exercice fails (server)", () => {
113 | beforeAll(async () => {
114 | await createExerciceUseCase(createExerciceCommand)(testStore.dispatch, testStore.getState, {
115 | exerciceRepository: new ExerciceErrorRepositoryFake(),
116 | },);
117 | });
118 | test("Then the status should be error", async () => {
119 | expect(getExerciceCreateStatus(testStore.getState())).toBe("error");
120 | });
121 |
122 | test("Then there should be no error", async () => {
123 | expect(getExerciceCreateError(testStore.getState())).toBe("Exercice création échouée");
124 | });
125 |
126 | test("Then the exercices list should be empty", async () => {
127 | expect(getExercicesListData(testStore.getState()).length).toBe(0);
128 | });
129 |
130 | test("Then it should add an error notification", () => {
131 | const createExerciceErrorNotification = getNotificationsList(testStore.getState()).find(
132 | (notification) => notification.message === "Exercice création échouée");
133 |
134 | expect(createExerciceErrorNotification).not.toBeUndefined();
135 | expect(createExerciceErrorNotification?.type).toBe(NotificationType.ERROR,);
136 | });
137 | });
138 |
139 | describe("When creating an exercice fails (title too short)", () => {
140 | beforeAll(async () => {
141 | const createExerciceCommandWithShortTitle = {
142 | ...createExerciceCommand,
143 | title: "R",
144 | };
145 |
146 | await createExerciceUseCase(createExerciceCommandWithShortTitle)(
147 | testStore.dispatch, testStore.getState, {
148 | exerciceRepository: new ExerciceSuccessRepositoryFake(),
149 | },);
150 | });
151 | test("Then the status should be error", async () => {
152 | expect(getExerciceCreateStatus(testStore.getState())).toBe("error");
153 | });
154 |
155 | test("Then the exercices list should be empty", async () => {
156 | expect(getExercicesListData(testStore.getState()).length).toBe(0);
157 | });
158 |
159 | test("Then it should add an error notification", () => {
160 | const createExerciceErrorNotification = getNotificationsList(testStore.getState()).find(
161 | (notification) => notification.message === "Exercice création échouée : titre trop court");
162 |
163 | expect(createExerciceErrorNotification).not.toBeUndefined();
164 | expect(createExerciceErrorNotification?.type).toBe(NotificationType.ERROR);
165 | });
166 | });
167 | });
168 | });
169 |
--------------------------------------------------------------------------------
/src/exercice/features/create-exercice/create-exercice.use-case.ts:
--------------------------------------------------------------------------------
1 | import {Thunk} from "@/src/shared/application/thunk.type";
2 | import {Dispatch} from "@reduxjs/toolkit";
3 | import {
4 | exerciceCreated, exerciceCreationFailed, exerciceCreationStarted,
5 | } from "@/src/exercice/features/create-exercice/create-exercice.events";
6 | import {
7 | exercicesLoaded, exercicesLoadingFailed, exercicesLoadingStarted,
8 | } from "@/src/exercice/features/list-exercices/list-exercices.events";
9 | import {Muscle} from "@/src/muscle/features/shared/muscle.model.type";
10 | import {validateExerciceService} from "@/src/exercice/features/create-exercice/create-exercice-validator.service";
11 |
12 | export type CreateExerciceCommand = {
13 | title: string;
14 | description: string | null;
15 | image: string | null;
16 | youtubeVideoUrl: string | null;
17 | primaryMuscles: Partial[];
18 | secondaryMuscles: Partial[];
19 | };
20 |
21 | // TODO: To refacto
22 | // kept simple here for the sake of the example
23 | export const createExerciceUseCase = (createExercice: CreateExerciceCommand): Thunk => async (
24 | dispatch: Dispatch, _, {exerciceRepository}) => {
25 | dispatch(exerciceCreationStarted());
26 |
27 | const errors = validateExerciceService(createExercice);
28 |
29 | if (errors.length > 0) {
30 | for (const error of errors) {
31 | dispatch(exerciceCreationFailed("Exercice création échouée : " + error));
32 | }
33 |
34 | return;
35 | }
36 |
37 | try {
38 | await exerciceRepository.create(createExercice);
39 |
40 | dispatch(exerciceCreated());
41 |
42 | dispatch(exercicesLoadingStarted());
43 |
44 | try {
45 | const exercices = await exerciceRepository.findAll();
46 |
47 | dispatch(exercicesLoaded(exercices));
48 | } catch (error: any) {
49 | const errorMessage = error instanceof Error ? error.message : "Exercice création échouée";
50 | dispatch(exercicesLoadingFailed(errorMessage));
51 | }
52 | } catch (error: any) {
53 | const errorMessage = error instanceof Error ? error.message : "Exercice création échouée";
54 | dispatch(exerciceCreationFailed(errorMessage));
55 | }
56 | };
57 |
58 |
--------------------------------------------------------------------------------
/src/exercice/features/delete-exercice/delete-exercice.events.ts:
--------------------------------------------------------------------------------
1 | import {createAction} from "@reduxjs/toolkit";
2 |
3 | export const exerciceDeletionStarted = createAction("EXERCICE_DELETION_STARTED",);
4 | export const exerciceDeleted = createAction("EXERCICE_DELETED");
5 | export const exerciceDeletionFailed = createAction("EXERCICE_DELETION_FAILED", (errorMessage: string) => ({
6 | payload: errorMessage,
7 | }));
--------------------------------------------------------------------------------
/src/exercice/features/delete-exercice/delete-exercice.reducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | exerciceDeleted, exerciceDeletionFailed, exerciceDeletionStarted,
3 | } from "@/src/exercice/features/delete-exercice/delete-exercice.events";
4 | import {createReducer} from "@reduxjs/toolkit";
5 | import {
6 | deleteExerciceInitialState, DeleteExerciceStatus
7 | } from "@/src/exercice/features/delete-exercice/delete-exercice.state.model";
8 |
9 | const deleteExerciceReducer = createReducer(deleteExerciceInitialState, (builder) => {
10 | builder
11 | .addCase(exerciceDeletionStarted, (state) => {
12 | state.status = DeleteExerciceStatus.LOADING;
13 | })
14 | .addCase(exerciceDeleted, (state) => {
15 | state.status = DeleteExerciceStatus.SUCCESS;
16 | })
17 |
18 | .addCase(exerciceDeletionFailed, (state, action) => {
19 | state.status = DeleteExerciceStatus.ERROR
20 | state.error = action.payload;
21 | });
22 |
23 | });
24 |
25 | export default deleteExerciceReducer;
26 |
--------------------------------------------------------------------------------
/src/exercice/features/delete-exercice/delete-exercice.state-machine.md:
--------------------------------------------------------------------------------
1 | ```mermaid
2 | ---
3 | title: Delete Exercice State
4 | ---
5 | flowchart TD
6 |
7 | A[
8 | Idle
9 |
10 | Status: idle
11 | Error: null
12 |
13 | Notifications: n
14 |
15 | List Exercices Data: n
16 | ]
17 |
18 | B[
19 | Loading
20 |
21 | Status: loading
22 | Error: null
23 |
24 | Notifications: n
25 |
26 | List Exercices Data: n
27 | ]
28 |
29 | C[
30 | Error
31 |
32 | Status: error
33 | Error: error message
34 |
35 | Notification: n + 1 error
36 |
37 | List Exercices Data: n
38 | ]
39 |
40 | D[
41 | Success
42 |
43 | Status: success
44 | Error: none
45 |
46 | Notification: n + 1 success
47 | ]
48 |
49 | E[
50 | List exercices Success
51 |
52 | ...
53 | Data: n - deleted exercice
54 | ]
55 |
56 | subgraph Delete Exercice
57 | A -->|Exercices deletion started|B
58 | B -->|Exercices deletion failed|C
59 | B -->|Exercices deletion success|D
60 | end
61 |
62 | subgraph List Exercices
63 | D -->|...|E
64 | end
65 |
66 | ```
67 |
--------------------------------------------------------------------------------
/src/exercice/features/delete-exercice/delete-exercice.state.model.ts:
--------------------------------------------------------------------------------
1 | import {RootState} from "@/src/shared/application/root.state";
2 |
3 | export type DeleteExerciceStateModel = {
4 | status: DeleteExerciceStatus;
5 | error: string | null;
6 | };
7 |
8 | export enum DeleteExerciceStatus {
9 | IDLE = "idle", LOADING = "loading", SUCCESS = "success", ERROR = "error",
10 | }
11 |
12 | export const deleteExerciceInitialState: DeleteExerciceStateModel = {
13 | status: DeleteExerciceStatus.IDLE,
14 | error: null
15 | };
16 | export const getDeleteExerciceStatus = (state: RootState) => state.exercices.delete.status;
17 | export const getDeleteExerciceError = (state: RootState) => state.exercices.delete.error;
18 |
--------------------------------------------------------------------------------
/src/exercice/features/delete-exercice/delete-exercice.use-case.spec.ts:
--------------------------------------------------------------------------------
1 | import {AppStore} from "@/src/shared/application/root.store";
2 |
3 | import {createTestStoreWithExercices} from "@/src/exercice/features/shared/test/utils/create-test-store-with-exercices";
4 | import {ExerciceLoadingRepositoryFake} from "@/src/exercice/features/shared/test/exercice-loading.repository.fake";
5 | import {deleteExerciceUseCase} from "@/src/exercice/features/delete-exercice/delete-exercice.use-case";
6 | import {ExerciceSuccessRepositoryFake} from "@/src/exercice/features/shared/test/exercice-success.repository.fake";
7 | import {getDeleteExerciceStatus} from "@/src/exercice/features/delete-exercice/delete-exercice.state.model";
8 | import {getExercicesListData} from "@/src/exercice/features/list-exercices/list-exercices.state.model";
9 | import {getNotificationsList, NotificationType} from "@/src/notification/features/shared/notification.state.model";
10 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.state.model";
11 |
12 | describe("As a user i want to delete a created exercice", () => {
13 | let exercicesCreated: Exercice[] | ExercicesSortedByMuscle[];
14 | let exerciceIdToDelete: string;
15 | let testStore: AppStore;
16 |
17 | describe("Given two exercices are already created", () => {
18 | beforeAll(async () => {
19 | testStore = await createTestStoreWithExercices();
20 | exercicesCreated = getExercicesListData(testStore.getState());
21 | });
22 |
23 | describe("When the exercice deletion has not started", () => {
24 | test("Then the status should be idle", async () => {
25 | expect(getDeleteExerciceStatus(testStore.getState())).toBe("idle");
26 | });
27 |
28 | test("Then the exercices list should still contains the created exercices", async () => {
29 | expect(getExercicesListData(testStore.getState())).toBe(exercicesCreated);
30 | });
31 | });
32 | });
33 |
34 | describe("Given two exercices are already created", () => {
35 | beforeAll(async () => {
36 | testStore = await createTestStoreWithExercices();
37 | exerciceIdToDelete = getExercicesListData(testStore.getState())[0].id;
38 | });
39 |
40 | describe("When the exercice deletion starts", () => {
41 | beforeAll(async () => {
42 | deleteExerciceUseCase(exerciceIdToDelete)(testStore.dispatch, testStore.getState, {
43 | exerciceRepository: new ExerciceLoadingRepositoryFake(),
44 | });
45 | });
46 |
47 | test("Then the status should be loading", async () => {
48 | expect(getDeleteExerciceStatus(testStore.getState())).toBe("loading");
49 | });
50 | });
51 | });
52 |
53 | describe("Given two exercices are already created", () => {
54 | beforeAll(async () => {
55 | testStore = await createTestStoreWithExercices();
56 | exercicesCreated = getExercicesListData(testStore.getState());
57 | exerciceIdToDelete = exercicesCreated[0].id;
58 | });
59 |
60 | describe("When the exercice is deleted successfully", () => {
61 | beforeEach(async () => {
62 | await deleteExerciceUseCase(exerciceIdToDelete)(testStore.dispatch, testStore.getState, {
63 | exerciceRepository: new ExerciceSuccessRepositoryFake(),
64 | },);
65 | });
66 |
67 | test("Then the status should be loading", async () => {
68 | expect(getDeleteExerciceStatus(testStore.getState())).toBe("success");
69 | });
70 |
71 | test("Then the exercice should be removed from the list", () => {
72 | expect(getExercicesListData(testStore.getState()).length).toBe(exercicesCreated.length - 1,);
73 |
74 | const ExerciceDeletedInStore = getExercicesListData(testStore.getState()).find(
75 | (exercice) => exercice.id === exerciceIdToDelete,);
76 | expect(ExerciceDeletedInStore).toBeUndefined();
77 | });
78 |
79 | test("Then it should set a success notification", () => {
80 | const deleteSuccessNotification = getNotificationsList(testStore.getState()).find(
81 | (notification) => notification.message === "Exercice suppression réussie",);
82 |
83 | expect(deleteSuccessNotification).not.toBeUndefined();
84 | expect(deleteSuccessNotification?.type).toBe(NotificationType.SUCCESS);
85 | });
86 | });
87 | });
88 |
89 | });
90 |
--------------------------------------------------------------------------------
/src/exercice/features/delete-exercice/delete-exercice.use-case.ts:
--------------------------------------------------------------------------------
1 | import {Thunk} from "@/src/shared/application/thunk.type";
2 | import {
3 | exerciceDeleted, exerciceDeletionStarted,
4 | } from "@/src/exercice/features/delete-exercice/delete-exercice.events";
5 | import {
6 | exercicesLoaded, exercicesLoadingFailed, exercicesLoadingStarted,
7 | } from "@/src/exercice/features/list-exercices/list-exercices.events";
8 | import {Dispatch} from "@reduxjs/toolkit";
9 | import {ExerciceRepositoryInterface} from "@/src/exercice/features/shared/exercice.repository.interface";
10 |
11 | export const deleteExerciceUseCase = (exerciceId: string): Thunk => async (dispatch, _, {exerciceRepository}) => {
12 | dispatch(exerciceDeletionStarted());
13 |
14 | await exerciceRepository.deleteById(exerciceId);
15 |
16 | dispatch(exerciceDeleted());
17 |
18 | fetchExercices(dispatch, exerciceRepository);
19 |
20 | };
21 |
22 | const fetchExercices = async (dispatch: Dispatch, exerciceRepository: ExerciceRepositoryInterface) => {
23 | dispatch(exercicesLoadingStarted());
24 |
25 | try {
26 | const exercices = await exerciceRepository.findAll();
27 |
28 | dispatch(exercicesLoaded(exercices));
29 | } catch (error: any) {
30 | const errorMessage = error instanceof Error ? error.message : "Exercice création échouée";
31 | dispatch(exercicesLoadingFailed(errorMessage));
32 | }
33 | }
--------------------------------------------------------------------------------
/src/exercice/features/get-exercice-by-id/get-exercice-by-id.events.ts:
--------------------------------------------------------------------------------
1 | import {createAction} from "@reduxjs/toolkit";
2 | import {Exercice} from "@/src/exercice/features/shared/exercice.state.model";
3 |
4 | export const exerciceLoadingStarted = createAction("EXERCICE_LOADING_STARTED");
5 | export const exerciceLoaded = createAction("EXERCICE_LOADED");
6 | export const exerciceLoadingFailed = createAction("EXERCICE_LOADING_FAILED", (errorMessage: string) => ({
7 | payload: errorMessage,
8 | }));
9 |
10 |
--------------------------------------------------------------------------------
/src/exercice/features/get-exercice-by-id/get-exercice-by-id.reducer.ts:
--------------------------------------------------------------------------------
1 | import {createReducer} from "@reduxjs/toolkit";
2 | import {
3 | exerciceLoaded, exerciceLoadingFailed, exerciceLoadingStarted,
4 | } from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.events";
5 | import {
6 | getExerciceByIdInitialState, GetExerciceByIdStatus
7 | } from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.state.model";
8 |
9 | const getExerciceByIdReducer = createReducer(getExerciceByIdInitialState, (builder) => {
10 | builder
11 | .addCase(exerciceLoadingStarted, (state) => {
12 | state.status = GetExerciceByIdStatus.LOADING;
13 | })
14 | .addCase(exerciceLoaded, (state, action) => {
15 | state.data = action.payload;
16 | state.status = GetExerciceByIdStatus.SUCCESS;
17 | })
18 | .addCase(exerciceLoadingFailed, (state, action) => {
19 | state.status = GetExerciceByIdStatus.ERROR;
20 | state.error = action.payload;
21 | });
22 | });
23 |
24 | export default getExerciceByIdReducer;
--------------------------------------------------------------------------------
/src/exercice/features/get-exercice-by-id/get-exercice-by-id.state-machine.md:
--------------------------------------------------------------------------------
1 | ```mermaid
2 | ---
3 | title: Get Exercice By Id State
4 | ---
5 | flowchart TD
6 |
7 | A[
8 | Idle
9 |
10 | Status: idle
11 | Error: null
12 | Data: null
13 |
14 | Notifications: n
15 | ]
16 |
17 | B[
18 | Loading
19 |
20 | Status: loading
21 | Error: null
22 | Data: null
23 |
24 | Notifications: n
25 | ]
26 |
27 | C[
28 | Error
29 |
30 | Status: error
31 | Error: error message
32 | Data: null
33 |
34 | Notification: n + 1 error
35 | ]
36 |
37 | D[
38 | Success
39 |
40 | Status: error
41 | Error: null
42 | Data: exercice
43 |
44 | Notification: n
45 | ]
46 |
47 | A -->|Exercice loading started|B
48 | B -->|Exercice loading failed|C
49 | B -->|Exercice loading success|D
50 | ```
51 |
52 |
--------------------------------------------------------------------------------
/src/exercice/features/get-exercice-by-id/get-exercice-by-id.state.model.ts:
--------------------------------------------------------------------------------
1 | import {RootState} from "@/src/shared/application/root.state";
2 | import {Exercice} from "@/src/exercice/features/shared/exercice.state.model";
3 |
4 | export type GetExerciceByIdStateModel = {
5 | data: Exercice | null;
6 | status: GetExerciceByIdStatus;
7 | error: string | null;
8 | };
9 |
10 | export enum GetExerciceByIdStatus {
11 | IDLE = "idle", LOADING = "loading", SUCCESS = "success", ERROR = "error",
12 | }
13 |
14 | export const getExerciceByIdInitialState: GetExerciceByIdStateModel = {
15 | data: null,
16 | status: GetExerciceByIdStatus.IDLE,
17 | error: null
18 | };
19 | export const getExerciceByIdStatus = (state: RootState) => state.exercices.getById.status;
20 |
21 | export const getExerciceByIdData = (state: RootState) => state.exercices.getById.data;
22 |
23 | export const getExerciceByIdError = (state: RootState) => state.exercices.getById.error;
--------------------------------------------------------------------------------
/src/exercice/features/get-exercice-by-id/get-exercice-by-id.usecase.spec.ts:
--------------------------------------------------------------------------------
1 | import {AppStore} from "@/src/shared/application/root.store";
2 | import {createTestStoreWithExercices} from "@/src/exercice/features/shared/test/utils/create-test-store-with-exercices";
3 | import {ExerciceLoadingRepositoryFake} from "@/src/exercice/features/shared/test/exercice-loading.repository.fake";
4 | import {getExerciceByIdUseCase} from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.usecase";
5 | import {ExerciceSuccessRepositoryFake} from "@/src/exercice/features/shared/test/exercice-success.repository.fake";
6 | import {ExerciceErrorRepositoryFake} from "@/src/exercice/features/shared/test/exercice-error.repository.fake";
7 | import {
8 | getExerciceByIdData, getExerciceByIdError, getExerciceByIdStatus
9 | } from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.state.model";
10 | import {getExercicesListData} from "@/src/exercice/features/list-exercices/list-exercices.state.model";
11 | import {getNotificationsList, NotificationType} from "@/src/notification/features/shared/notification.state.model";
12 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.state";
13 |
14 | let testStore: AppStore;
15 |
16 | describe("As a user i want to get an exercice by its id", () => {
17 | let exercices: Exercice[] | ExercicesSortedByMuscle[];
18 | let exerciceIdToRetrieve: string;
19 |
20 | describe("Given two exercices are created", () => {
21 | beforeAll(async () => {
22 | testStore = await createTestStoreWithExercices();
23 | exercices = getExercicesListData(testStore.getState());
24 | exerciceIdToRetrieve = exercices[0].id;
25 | });
26 |
27 | describe("When the exercice fetching has not started", () => {
28 |
29 | test("Then the status should be idle", async () => {
30 | expect(getExerciceByIdStatus(testStore.getState())).toBe("idle");
31 | });
32 |
33 | test("Then the data should not contains the exercice", async () => {
34 | expect(getExerciceByIdData(testStore.getState())).toBe(null);
35 | });
36 |
37 | test("Then there should be no error", async () => {
38 | expect(getExerciceByIdError(testStore.getState())).toBe(null);
39 | });
40 | });
41 | });
42 |
43 | describe("Given two exercices are created", () => {
44 | beforeAll(async () => {
45 | testStore = await createTestStoreWithExercices();
46 | exercices = getExercicesListData(testStore.getState());
47 | exerciceIdToRetrieve = exercices[0].id;
48 | });
49 |
50 | describe("When the exercice fetching starts", () => {
51 | beforeAll(async () => {
52 | getExerciceByIdUseCase(exerciceIdToRetrieve)(testStore.dispatch, testStore.getState, {
53 | exerciceRepository: new ExerciceLoadingRepositoryFake(),
54 | },);
55 | });
56 |
57 | test("Then the status should be loading", async () => {
58 | expect(getExerciceByIdStatus(testStore.getState())).toBe("loading");
59 | });
60 |
61 | test("Then the data should not contains the exercice", async () => {
62 | expect(getExerciceByIdData(testStore.getState())).toBe(null);
63 | });
64 |
65 | test("Then there should be no error", async () => {
66 | expect(getExerciceByIdError(testStore.getState())).toBe(null);
67 | });
68 |
69 | });
70 | });
71 |
72 | describe("Given two exercices are created", () => {
73 | beforeAll(async () => {
74 | testStore = await createTestStoreWithExercices();
75 | exercices = getExercicesListData(testStore.getState());
76 | exerciceIdToRetrieve = exercices[0].id;
77 | });
78 |
79 | describe("When the exercice is retrieved successfully", () => {
80 | beforeAll(async () => {
81 | await getExerciceByIdUseCase(exerciceIdToRetrieve)(testStore.dispatch, testStore.getState, {
82 | exerciceRepository: new ExerciceSuccessRepositoryFake(),
83 | },);
84 | });
85 |
86 | test("Then the status should be success", async () => {
87 | expect(getExerciceByIdStatus(testStore.getState())).toBe("success");
88 | });
89 |
90 | test("Then the data should contains the exercice", async () => {
91 | expect(getExerciceByIdData(testStore.getState())).toStrictEqual(exercices[0]);
92 | });
93 |
94 | test("Then there should be no error", async () => {
95 | expect(getExerciceByIdError(testStore.getState())).toBe(null);
96 | });
97 | test("Then it should set a success notification", async () => {
98 | const getSuccessNotification = getNotificationsList(testStore.getState()).find(
99 | (notification) => notification.message === "Exercice récupération réussie",);
100 |
101 | expect(getSuccessNotification).not.toBeUndefined();
102 | expect(getSuccessNotification?.type).toBe(NotificationType.SUCCESS);
103 | });
104 | });
105 | });
106 |
107 | describe("Given two exercices are created", () => {
108 | beforeAll(async () => {
109 | testStore = await createTestStoreWithExercices();
110 | exercices = getExercicesListData(testStore.getState());
111 | exerciceIdToRetrieve = exercices[0].id;
112 | });
113 |
114 | describe("When the exercice retrieval fails", () => {
115 | beforeAll(async () => {
116 | await getExerciceByIdUseCase(exerciceIdToRetrieve)(testStore.dispatch, testStore.getState, {
117 | exerciceRepository: new ExerciceErrorRepositoryFake(),
118 | },);
119 | });
120 |
121 | test("Then the status should be error", async () => {
122 | expect(getExerciceByIdStatus(testStore.getState())).toBe("error");
123 | });
124 |
125 | test("Then there should be an error", async () => {
126 | expect(getExerciceByIdError(testStore.getState())).toBe("Exercice récupération échouée");
127 | });
128 |
129 | test("Then the data should not contains the exercice", async () => {
130 | expect(getExerciceByIdData(testStore.getState())).toBe(null);
131 | });
132 |
133 | test("Then it should set an error notification", async () => {
134 | const getErrorNotification = getNotificationsList(testStore.getState()).find(
135 | (notification) => notification.message === "Exercice récupération échouée",);
136 |
137 | expect(getErrorNotification).not.toBeUndefined();
138 | expect(getErrorNotification?.type).toBe(NotificationType.ERROR);
139 | });
140 | });
141 | });
142 |
143 | });
144 |
--------------------------------------------------------------------------------
/src/exercice/features/get-exercice-by-id/get-exercice-by-id.usecase.ts:
--------------------------------------------------------------------------------
1 | import {Thunk} from "@/src/shared/application/thunk.type";
2 | import {
3 | exerciceLoaded, exerciceLoadingFailed, exerciceLoadingStarted,
4 | } from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.events";
5 |
6 | export const getExerciceByIdUseCase = (exerciceId: string): Thunk => async (dispatch, _, {exerciceRepository}) => {
7 | dispatch(exerciceLoadingStarted());
8 |
9 | try {
10 | const exercice = await exerciceRepository.findById(exerciceId);
11 |
12 | dispatch(exerciceLoaded(exercice));
13 | } catch (error: any) {
14 | const errorMessage = error instanceof Error ? error.message : "An error occurred while loading the exercice.";
15 | dispatch(exerciceLoadingFailed(errorMessage));
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/src/exercice/features/list-exercices/list-exercices-sort.enum.ts:
--------------------------------------------------------------------------------
1 | export enum ExerciceSortEnum {
2 | "MUSCLE_GROUP" = "muscleGroup",
3 | }
4 |
--------------------------------------------------------------------------------
/src/exercice/features/list-exercices/list-exercices.events.ts:
--------------------------------------------------------------------------------
1 | import {createAction} from "@reduxjs/toolkit";
2 | import {Exercice, ExercicesSortedByMuscle,} from "@/src/exercice/features/shared/exercice.model.type";
3 |
4 | export const exercicesLoadingStarted = createAction(
5 | "EXERCICES_LOADING_STARTED",
6 | );
7 | export const exercicesLoaded = createAction<
8 | ExercicesSortedByMuscle[] | Exercice[]
9 | >("EXERCICES_LOADED");
10 | export const exercicesLoadingFailed = createAction(
11 | "EXERCICES_LOADING_FAILED",
12 | );
13 |
--------------------------------------------------------------------------------
/src/exercice/features/list-exercices/list-exercices.reducer.ts:
--------------------------------------------------------------------------------
1 | import {createReducer} from "@reduxjs/toolkit";
2 | import {
3 | exercicesLoaded, exercicesLoadingFailed, exercicesLoadingStarted
4 | } from "@/src/exercice/features/list-exercices/list-exercices.events";
5 | import {
6 | listExercicesInitialState, ListExercicesStatus
7 | } from "@/src/exercice/features/list-exercices/list-exercices.state.model";
8 |
9 | const listExercicesReducer = createReducer(listExercicesInitialState, (builder) => {
10 | builder
11 | .addCase(exercicesLoadingStarted, (state) => {
12 | state.status = ListExercicesStatus.LOADING;
13 | })
14 | .addCase(exercicesLoaded, (state, action) => {
15 | state.data = action.payload;
16 | state.status = ListExercicesStatus.SUCCESS;
17 | })
18 | .addCase(exercicesLoadingFailed, (state, action) => {
19 | state.error = action.payload;
20 | state.status = ListExercicesStatus.ERROR;
21 | });
22 | });
23 |
24 | export default listExercicesReducer;
--------------------------------------------------------------------------------
/src/exercice/features/list-exercices/list-exercices.state-machine.md:
--------------------------------------------------------------------------------
1 | ```mermaid
2 | ---
3 | title: List Exercices State
4 | ---
5 | flowchart TD
6 |
7 | A[
8 | Idle
9 |
10 | Status: idle
11 | Error: null
12 | Data: null
13 |
14 | Notifications: n
15 | ]
16 |
17 | B[
18 | Loading
19 |
20 | Status: loading
21 | Error: null
22 | Data: null
23 |
24 | Notifications: n
25 | ]
26 |
27 | C[
28 | Error
29 |
30 | Status: error
31 | Error: error message
32 | Data: null
33 |
34 | Notification: n + 1 error
35 | ]
36 |
37 | D[
38 | Success
39 |
40 | Status: error
41 | Error: null
42 | Data: exercices
43 |
44 | Notification: n
45 | ]
46 |
47 | A -->|Exercices loading Started|B
48 | B -->|Exercices loading failed|C
49 | B -->|Exercices Loaded|D
50 |
51 | ```
--------------------------------------------------------------------------------
/src/exercice/features/list-exercices/list-exercices.state.model.ts:
--------------------------------------------------------------------------------
1 | import {RootState} from "@/src/shared/application/root.state";
2 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.state.model";
3 |
4 | export type ListExercicesStateModel = {
5 | data: Exercice[] | ExercicesSortedByMuscle[];
6 | status: ListExercicesStatus;
7 | error: string | null;
8 | };
9 |
10 | export enum ListExercicesStatus {
11 | IDLE = "idle", LOADING = "loading", SUCCESS = "success", ERROR = "error",
12 | }
13 |
14 | export const listExercicesInitialState: ListExercicesStateModel = {
15 | data: [],
16 | status: ListExercicesStatus.IDLE,
17 | error: null,
18 | };
19 | export const getExercicesListError = (state: RootState) => state.exercices.list.error;
20 |
21 | export const getExercicesListStatus = (state: RootState) => state.exercices.list.status;
22 |
23 | export const getExercicesListData = (state: RootState) => state.exercices.list.data;
--------------------------------------------------------------------------------
/src/exercice/features/list-exercices/list-exercices.use-case.spec.ts:
--------------------------------------------------------------------------------
1 | import {AppStore} from "@/src/shared/application/root.store";
2 | import {listExercicesUseCase} from "@/src/exercice/features/list-exercices/list-exercices.use-case";
3 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.model.type";
4 | import {ExerciceErrorRepositoryFake} from "@/src/exercice/features/shared/test/exercice-error.repository.fake";
5 | import {ExerciceLoadingRepositoryFake} from "@/src/exercice/features/shared/test/exercice-loading.repository.fake";
6 | import {ExerciceSuccessRepositoryFake} from "@/src/exercice/features/shared/test/exercice-success.repository.fake";
7 | import {createTestStore} from "@/src/shared/application/test/test.store";
8 | import {
9 | getExercicesListData, getExercicesListError, getExercicesListStatus
10 | } from "@/src/exercice/features/list-exercices/list-exercices.state.model";
11 | import {getNotificationsList, NotificationType} from "@/src/notification/features/shared/notification.state.model";
12 |
13 | describe("As a user i want to get all exercices", () => {
14 | let testStore: AppStore;
15 | let exercices: Exercice[] | ExercicesSortedByMuscle[];
16 |
17 | describe("Given two exercices are created", () => {
18 | beforeAll(async () => {
19 | testStore = createTestStore();
20 | const exerciceSuccessRepository = new ExerciceSuccessRepositoryFake();
21 | exercices = await exerciceSuccessRepository.findAll();
22 | });
23 | describe("When the the exercices fetching has not started", () => {
24 | test("Then the status should be idle", async () => {
25 | expect(getExercicesListStatus(testStore.getState())).toBe("idle");
26 | });
27 |
28 | test("Then the data should not contains the exercices", async () => {
29 | expect(getExercicesListData(testStore.getState()).length).toBe(0);
30 | });
31 |
32 | test("Then there should be no error", async () => {
33 | expect(getExercicesListError(testStore.getState())).toBe(null);
34 | });
35 | });
36 | });
37 |
38 | describe("Given two exercices are created", () => {
39 | beforeAll(async () => {
40 | testStore = createTestStore();
41 | const exerciceSuccessRepository = new ExerciceSuccessRepositoryFake();
42 | exercices = await exerciceSuccessRepository.findAll();
43 | });
44 | describe("When the exercices retrieval starts", () => {
45 | beforeAll(async () => {
46 | listExercicesUseCase()(testStore.dispatch, testStore.getState, {
47 | exerciceRepository: new ExerciceLoadingRepositoryFake(),
48 | });
49 | });
50 |
51 | test("Then the status should be loading", async () => {
52 | expect(getExercicesListStatus(testStore.getState())).toBe("loading");
53 | });
54 |
55 | test("Then the data should not contains the exercices", async () => {
56 | expect(getExercicesListData(testStore.getState()).length).toBe(0);
57 | });
58 |
59 | test("Then there should be no error", async () => {
60 | expect(getExercicesListError(testStore.getState())).toBe(null);
61 | });
62 | });
63 | });
64 |
65 | describe("Given two exercices are created", () => {
66 | beforeAll(async () => {
67 | testStore = createTestStore();
68 | const exerciceSuccessRepository = new ExerciceSuccessRepositoryFake();
69 | exercices = await exerciceSuccessRepository.findAll();
70 | });
71 | describe("When the exercices are retrieved successfully", () => {
72 | beforeAll(async () => {
73 | await listExercicesUseCase()(testStore.dispatch, testStore.getState, {
74 | exerciceRepository: new ExerciceSuccessRepositoryFake(),
75 | });
76 | });
77 |
78 | test("Then the status should be success", async () => {
79 | expect(getExercicesListStatus(testStore.getState())).toBe("success");
80 | });
81 |
82 | test("Then the data should contains the exercices", async () => {
83 | expect(getExercicesListData(testStore.getState())).toEqual(exercices);
84 | });
85 |
86 | test("Then there should be no error", async () => {
87 | expect(getExercicesListError(testStore.getState())).toBe(null);
88 | });
89 | });
90 | });
91 |
92 | describe("Given two exercices are created", () => {
93 | beforeAll(async () => {
94 | testStore = createTestStore();
95 | const exerciceSuccessRepository = new ExerciceSuccessRepositoryFake();
96 | exercices = await exerciceSuccessRepository.findAll();
97 | });
98 | describe("When the exercices loading fails", () => {
99 | beforeAll(async () => {
100 | listExercicesUseCase()(testStore.dispatch, testStore.getState, {
101 | exerciceRepository: new ExerciceErrorRepositoryFake(),
102 | });
103 | });
104 |
105 | test("Then the status should be error", async () => {
106 | expect(getExercicesListStatus(testStore.getState())).toBe("error");
107 | });
108 |
109 | test("Then the data should not contains the exercices", async () => {
110 | expect(getExercicesListData(testStore.getState()).length).toEqual(0);
111 | });
112 |
113 | test("Then there should be an error", async () => {
114 | expect(getExercicesListError(testStore.getState())).toBe("Exercices récupération échouée");
115 | });
116 |
117 | test("Then it should set an error message", async () => {
118 | const getErrorNotification = getNotificationsList(testStore.getState()).find(
119 | (notification) => notification.message === "Exercices récupération échouée");
120 | expect(getErrorNotification).not.toBeUndefined();
121 | expect(getErrorNotification?.type).toBe(NotificationType.ERROR);
122 | });
123 |
124 | });
125 | });
126 | });
127 |
--------------------------------------------------------------------------------
/src/exercice/features/list-exercices/list-exercices.use-case.ts:
--------------------------------------------------------------------------------
1 | import {Thunk} from "@/src/shared/application/thunk.type";
2 | import {ExerciceSortEnum} from "@/src/exercice/features/list-exercices/list-exercices-sort.enum";
3 | import {
4 | exercicesLoaded, exercicesLoadingFailed, exercicesLoadingStarted,
5 | } from "@/src/exercice/features/list-exercices/list-exercices.events";
6 |
7 | export const listExercicesUseCase = (sort?: ExerciceSortEnum): Thunk => async (dispatch, _, {exerciceRepository}) => {
8 | dispatch(exercicesLoadingStarted());
9 |
10 | try {
11 | const exercices = await exerciceRepository.findAll(sort);
12 |
13 | dispatch(exercicesLoaded(exercices));
14 | } catch (error: any) {
15 | const errorMessage = error instanceof Error ? error.message : "An error occurred while loading the exercices.";
16 |
17 | dispatch(exercicesLoadingFailed(errorMessage));
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/src/exercice/features/shared/exercice.reducer.ts:
--------------------------------------------------------------------------------
1 | import {combineReducers} from "@reduxjs/toolkit";
2 | import createExerciceReducer from "@/src/exercice/features/create-exercice/create-exercice.reducer";
3 | import getExerciceByIdReducer from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.reducer";
4 | import updateExerciceReducer from "@/src/exercice/features/update-exercice/update-exercice.reducer";
5 | import {Reducer} from "redux";
6 | import listExercicesReducer from "@/src/exercice/features/list-exercices/list-exercices.reducer";
7 | import deleteExerciceReducer from "@/src/exercice/features/delete-exercice/delete-exercice.reducer";
8 | import {ExercicesState} from "@/src/exercice/features/shared/exercice.state.model";
9 |
10 | const exercicesReducer: Reducer = combineReducers({
11 | delete: deleteExerciceReducer,
12 | update: updateExerciceReducer,
13 | create: createExerciceReducer,
14 | list: listExercicesReducer,
15 | getById: getExerciceByIdReducer,
16 |
17 | });
18 |
19 | export default exercicesReducer;
20 |
--------------------------------------------------------------------------------
/src/exercice/features/shared/exercice.repository.interface.ts:
--------------------------------------------------------------------------------
1 | import {ExerciceSortEnum} from "@/src/exercice/features/list-exercices/list-exercices-sort.enum";
2 | import {CreateExerciceCommand} from "@/src/exercice/features/create-exercice/create-exercice.use-case";
3 | import {UpdateExerciceCommand} from "@/src/exercice/features/update-exercice/update-exercice.usecase";
4 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.state.model";
5 |
6 | export interface ExerciceRepositoryInterface {
7 | create(createExerciceDto: CreateExerciceCommand): Promise;
8 |
9 | update(exerciceId: string, updateExerciceDto: UpdateExerciceCommand,): Promise;
10 |
11 | findAll(sort?: ExerciceSortEnum): Promise;
12 |
13 | deleteById(exerciceId: string): Promise;
14 |
15 | findById(exerciceId: string): Promise;
16 | }
17 |
--------------------------------------------------------------------------------
/src/exercice/features/shared/exercice.state.model.ts:
--------------------------------------------------------------------------------
1 | import {Muscle} from "@/src/muscle/features/shared/muscle.model.type";
2 | import {ListExercicesStateModel} from "@/src/exercice/features/list-exercices/list-exercices.state.model";
3 |
4 | import {UpdateExerciceStateModel} from "@/src/exercice/features/update-exercice/update-exercice.state.model";
5 | import {GetExerciceByIdStateModel} from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.state.model";
6 | import {CreateExerciceStateModel} from "@/src/exercice/features/create-exercice/create-exercice.state.model";
7 | import {DeleteExerciceStateModel} from "@/src/exercice/features/delete-exercice/delete-exercice.state.model";
8 |
9 | export type ExercicesState = {
10 | list: ListExercicesStateModel;
11 | create: CreateExerciceStateModel;
12 | getById: GetExerciceByIdStateModel;
13 | update: UpdateExerciceStateModel;
14 | delete: DeleteExerciceStateModel;
15 | };
16 |
17 | export type Exercice = {
18 | id: string;
19 | title: string;
20 | description: string | null;
21 | image: string | null;
22 | youtubeVideoUrl: string | null;
23 | createdAt: string;
24 | updatedAt: string;
25 | primaryMuscles: Partial[];
26 | secondaryMuscles: Partial[];
27 | };
28 |
29 | export type ExercicesSortedByMuscle = {
30 | id: string;
31 | title: string;
32 | exercises: Exercice[];
33 | children: ExercicesSortedByMuscle[];
34 | };
35 |
--------------------------------------------------------------------------------
/src/exercice/features/shared/infrastructure/exercice.repository.in-memory.ts:
--------------------------------------------------------------------------------
1 | // pas de repo par features en VSA
2 | // car chaque feature peut avoir besoin de plusieurs méthodes de repo
3 | // et sur le repo in memory on travaille sur la même liste d'exercices quand on add, delete etc
4 |
5 | import {ExerciceSortEnum} from "@/src/exercice/features/list-exercices/list-exercices-sort.enum";
6 | import {Exercice, ExercicesSortedByMuscle,} from "@/src/exercice/features/shared/exercice.model.type";
7 | import {ExerciceRepositoryInterface} from "@/src/exercice/features/shared/exercice.repository.interface";
8 | import {CreateExerciceCommand} from "@/src/exercice/features/create-exercice/create-exercice.use-case";
9 | import {UpdateExerciceCommand} from "@/src/exercice/features/update-exercice/update-exercice.usecase";
10 |
11 | export default class ExerciceRepositoryInMemory implements ExerciceRepositoryInterface {
12 | private exercices: Exercice[] = [{
13 | id: "1",
14 | title: "Incline Bench Press",
15 | description: "Works the upper chest area.",
16 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
17 | youtubeVideoUrl: "youtubeVideoUrl",
18 | createdAt: new Date().toISOString(),
19 | updatedAt: new Date().toISOString(),
20 | primaryMuscles: [{id: "101"}],
21 | secondaryMuscles: [],
22 | }, {
23 | id: "2",
24 | title: "Incline Dumbbell Press",
25 | description: "Targets the upper chest region.",
26 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
27 | youtubeVideoUrl: "youtubeVideoUrl",
28 | createdAt: new Date().toISOString(),
29 | updatedAt: new Date().toISOString(),
30 | primaryMuscles: [{id: "101"}],
31 | secondaryMuscles: [],
32 | }, {
33 | id: "3",
34 | title: "Bench Press",
35 | description: "Classic chest exercise.",
36 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
37 | youtubeVideoUrl: "youtubeVideoUrl",
38 | createdAt: new Date().toISOString(),
39 | updatedAt: new Date().toISOString(),
40 | primaryMuscles: [{id: "102"}],
41 | secondaryMuscles: [],
42 | }, {
43 | id: "4",
44 | title: "Decline Bench Press",
45 | description: "Focuses on the lower chest area.",
46 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
47 | youtubeVideoUrl: "youtubeVideoUrl",
48 | createdAt: new Date().toISOString(),
49 | updatedAt: new Date().toISOString(),
50 | primaryMuscles: [{id: "103"}],
51 | secondaryMuscles: [],
52 | }, {
53 | id: "5",
54 | title: "Push-Up",
55 | description: "Overall chest exercise.",
56 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
57 | youtubeVideoUrl: "youtubeVideoUrl",
58 | createdAt: new Date().toISOString(),
59 | updatedAt: new Date().toISOString(),
60 | primaryMuscles: [{id: "1"}],
61 | secondaryMuscles: [],
62 | }, {
63 | id: "6",
64 | title: "Shrugs",
65 | description: "Targets the traps.",
66 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
67 | youtubeVideoUrl: "youtubeVideoUrl",
68 | createdAt: new Date().toISOString(),
69 | updatedAt: new Date().toISOString(),
70 | primaryMuscles: [{id: "201"}],
71 | secondaryMuscles: [],
72 | }, {
73 | id: "7",
74 | title: "Lat Pulldown",
75 | description: "Focuses on the lats.",
76 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
77 | youtubeVideoUrl: "youtubeVideoUrl",
78 | createdAt: new Date().toISOString(),
79 | updatedAt: new Date().toISOString(),
80 | primaryMuscles: [{id: "202"}],
81 | secondaryMuscles: [],
82 | }, {
83 | id: "8",
84 | title: "Face Pull",
85 | description: "Engages the rhomboids.",
86 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
87 | youtubeVideoUrl: "youtubeVideoUrl",
88 | createdAt: new Date().toISOString(),
89 | updatedAt: new Date().toISOString(),
90 | primaryMuscles: [{id: "203"}],
91 | secondaryMuscles: [],
92 | }, {
93 | id: "9",
94 | title: "Deadlift",
95 | description: "Overall back exercise.",
96 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
97 | youtubeVideoUrl: "youtubeVideoUrl",
98 | createdAt: new Date().toISOString(),
99 | updatedAt: new Date().toISOString(),
100 | primaryMuscles: [{id: "2"}],
101 | secondaryMuscles: [],
102 | }, {
103 | id: "10",
104 | title: "Squat",
105 | description: "Works the quads primarily.",
106 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
107 | youtubeVideoUrl: "youtubeVideoUrl",
108 | createdAt: new Date().toISOString(),
109 | updatedAt: new Date().toISOString(),
110 | primaryMuscles: [{id: "301"}],
111 | secondaryMuscles: [],
112 | }, {
113 | id: "11",
114 | title: "Leg Curl",
115 | description: "Targets the hamstrings.",
116 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
117 | youtubeVideoUrl: "youtubeVideoUrl",
118 | createdAt: new Date().toISOString(),
119 | updatedAt: new Date().toISOString(),
120 | primaryMuscles: [{id: "302"}],
121 | secondaryMuscles: [],
122 | }, {
123 | id: "12",
124 | title: "Seated Calf Raise",
125 | description: "Focuses on the soleus.",
126 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
127 | youtubeVideoUrl: "youtubeVideoUrl",
128 | createdAt: new Date().toISOString(),
129 | updatedAt: new Date().toISOString(),
130 | primaryMuscles: [{id: "30301"}],
131 | secondaryMuscles: [],
132 | }, {
133 | id: "13",
134 | title: "Standing Calf Raise",
135 | description: "Engages the gastrocnemius.",
136 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
137 | youtubeVideoUrl: "youtubeVideoUrl",
138 | createdAt: new Date().toISOString(),
139 | updatedAt: new Date().toISOString(),
140 | primaryMuscles: [{id: "30302"}],
141 | secondaryMuscles: [],
142 | },];
143 |
144 | private musclesHierarchy = [{
145 | id: "1",
146 | name: "Chest",
147 | children: [{
148 | id: "101",
149 | name: "Upper Chest",
150 | children: [],
151 | }, {
152 | id: "102",
153 | name: "Middle Chest",
154 | children: [],
155 | }, {
156 | id: "103",
157 | name: "Lower Chest",
158 | children: [],
159 | },],
160 | }, {
161 | id: "2",
162 | name: "Back",
163 | children: [{
164 | id: "201",
165 | name: "Traps",
166 | children: [],
167 | }, {
168 | id: "202",
169 | name: "Lats",
170 | children: [],
171 | }, {
172 | id: "203",
173 | name: "Rhomboids",
174 | children: [],
175 | },],
176 | }, {
177 | id: "3",
178 | name: "Legs",
179 | children: [{
180 | id: "301",
181 | name: "Quads",
182 | children: [],
183 | }, {
184 | id: "302",
185 | name: "Hamstrings",
186 | children: [],
187 | }, {
188 | id: "303",
189 | name: "Calves",
190 | children: [{
191 | id: "30301",
192 | name: "Soleus",
193 | children: [],
194 | }, {
195 | id: "30302",
196 | name: "Gastrocnemius",
197 | children: [],
198 | },],
199 | },],
200 | },];
201 |
202 | public async create(createExerciceDto: CreateExerciceCommand): Promise {
203 | const newExercice: Exercice = {
204 | id: String(new Date().getTime()),
205 | title: createExerciceDto.title,
206 | description: createExerciceDto.description,
207 | image: createExerciceDto.image,
208 | youtubeVideoUrl: createExerciceDto.youtubeVideoUrl,
209 | createdAt: new Date(),
210 | updatedAt: new Date(),
211 | primaryMuscles: createExerciceDto.primaryMuscles,
212 | secondaryMuscles: createExerciceDto.secondaryMuscles,
213 | };
214 |
215 | this.exercices.push(newExercice);
216 | }
217 |
218 | public async findAll(sort?: ExerciceSortEnum = ExerciceSortEnum.MUSCLE_GROUP): Promise {
219 | if (sort === ExerciceSortEnum.MUSCLE_GROUP) {
220 | const buildHierarchy = (muscles: any[]): ExercicesSortedByMuscle[] => {
221 | return muscles.map((muscle) => ({
222 | id: muscle.id,
223 | title: muscle.name,
224 | exercises: this.exercices.filter(
225 | (ex) => ex.primaryMuscles.some((primaryMuscle) => primaryMuscle.id === muscle.id,),),
226 | children: buildHierarchy(muscle.children),
227 | }));
228 | };
229 |
230 | return buildHierarchy(this.musclesHierarchy);
231 | }
232 |
233 | return this.exercices.map((exercice) => ({
234 | ...exercice,
235 | }));
236 | }
237 |
238 | public async deleteById(id: string): Promise {
239 | const index = this.exercices.findIndex((ex) => ex.id === id);
240 | if (index === -1) {
241 | throw new Error("Exercice not found");
242 | }
243 |
244 | this.exercices.splice(index, 1);
245 | }
246 |
247 | public async update(exerciceId: string, updateExerciceDto: UpdateExerciceCommand,): Promise {
248 | const index = this.exercices.findIndex((ex) => ex.id === exerciceId);
249 | if (index === -1) {
250 | throw new Error("Exercice not found");
251 | }
252 |
253 | const updatedExercice = {
254 | ...this.exercices[index], ...updateExerciceDto,
255 | updatedAt: new Date(),
256 | };
257 |
258 | this.exercices[index] = updatedExercice;
259 | }
260 |
261 | public async findById(id: string): Promise {
262 | const exercice = this.exercices.find((ex) => ex.id === id);
263 |
264 | if (!exercice) {
265 | throw new Error("Exercice not found");
266 | }
267 |
268 | return exercice;
269 | }
270 | }
271 |
--------------------------------------------------------------------------------
/src/exercice/features/shared/test/exercice-error.repository.fake.ts:
--------------------------------------------------------------------------------
1 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.model.type";
2 | import {ExerciceRepositoryInterface} from "@/src/exercice/features/shared/exercice.repository.interface";
3 | import {ExerciceSortEnum} from "@/src/exercice/features/list-exercices/list-exercices-sort.enum";
4 | import {CreateExerciceCommand} from "@/src/exercice/features/create-exercice/create-exercice.use-case";
5 | import {UpdateExerciceCommand} from "@/src/exercice/features/update-exercice/update-exercice.usecase";
6 |
7 | export class ExerciceErrorRepositoryFake implements ExerciceRepositoryInterface {
8 | exercices = [{
9 | id: "1",
10 | title: "Incline Bench Press",
11 | description: "Works the upper chest area.",
12 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
13 | youtubeVideoUrl: "youtubeVideoUrl",
14 | createdAt: new Date().toISOString(),
15 | updatedAt: new Date().toISOString(),
16 | primaryMuscles: [{id: "101"}],
17 | secondaryMuscles: [],
18 | }, {
19 | id: "2",
20 | title: "Deadlift",
21 | description: "Works the lower back and legs.",
22 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
23 | youtubeVideoUrl: "youtubeVideoUrl",
24 | createdAt: new Date().toISOString(),
25 | updatedAt: new Date().toISOString(),
26 | primaryMuscles: [{id: "102"}],
27 | secondaryMuscles: [],
28 | },];
29 |
30 | async findAll(sort?: ExerciceSortEnum,): Promise {
31 | throw new Error("Exercices récupération échouée");
32 | }
33 |
34 | async findById(exerciceId: string): Promise {
35 | throw new Error("Exercice récupération échouée");
36 | }
37 |
38 | async create(exercice: CreateExerciceCommand): Promise {
39 | throw new Error("Exercice création échouée");
40 | }
41 |
42 | async update(exerciceId: string, updateExerciceDto: UpdateExerciceCommand,): Promise {
43 | throw new Error("Exercice maj échouée");
44 | }
45 |
46 | async deleteById(id: string): Promise {
47 | throw new Error("Exercice suppression échouée");
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/exercice/features/shared/test/exercice-loading.repository.fake.ts:
--------------------------------------------------------------------------------
1 | import {ExerciceRepositoryInterface} from "@/src/exercice/features/shared/exercice.repository.interface";
2 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.model.type";
3 | import {ExerciceSortEnum} from "@/src/exercice/features/list-exercices/list-exercices-sort.enum";
4 | import {CreateExerciceCommand} from "@/src/exercice/features/create-exercice/create-exercice.use-case";
5 | import {UpdateExerciceCommand} from "@/src/exercice/features/update-exercice/update-exercice.usecase";
6 |
7 | export class ExerciceLoadingRepositoryFake implements ExerciceRepositoryInterface {
8 | async findAll(sort?: ExerciceSortEnum,): Promise {
9 | return new Promise(() => {
10 | });
11 | }
12 |
13 | async findById(exerciceId: string): Promise {
14 | return new Promise(() => {
15 | });
16 | }
17 |
18 | async create(exercice: CreateExerciceCommand): Promise {
19 | return new Promise(() => {
20 | });
21 | }
22 |
23 | async update(exerciceId: string, updateExerciceDto: UpdateExerciceCommand,): Promise {
24 | return new Promise(() => {
25 | });
26 | }
27 |
28 | async deleteById(id: string): Promise {
29 | return new Promise(() => {
30 | });
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/exercice/features/shared/test/exercice-success.repository.fake.ts:
--------------------------------------------------------------------------------
1 | import {ExerciceRepositoryInterface} from "@/src/exercice/features/shared/exercice.repository.interface";
2 | import {ExerciceSortEnum} from "@/src/exercice/features/list-exercices/list-exercices-sort.enum";
3 | import {CreateExerciceCommand} from "@/src/exercice/features/create-exercice/create-exercice.use-case";
4 | import {UpdateExerciceCommand} from "@/src/exercice/features/update-exercice/update-exercice.usecase";
5 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.state";
6 |
7 | export class ExerciceSuccessRepositoryFake implements ExerciceRepositoryInterface {
8 | private exercices = [{
9 | id: "1",
10 | title: "Incline Bench Press",
11 | description: "Works the upper chest area.",
12 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
13 | youtubeVideoUrl: "youtubeVideoUrl",
14 | createdAt: new Date("2024-10-20").toISOString(),
15 | updatedAt: new Date("2024-10-20").toISOString(),
16 | primaryMuscles: [{id: "101"}],
17 | secondaryMuscles: [],
18 | }, {
19 | id: "2",
20 | title: "Deadlift",
21 | description: "Works the lower back and legs.",
22 | image: "https://i0.wp.com/muscu-street-et-crossfit.fr/wp-content/uploads/2022/03/Muscles-DM-Halteres.002.jpeg",
23 | youtubeVideoUrl: "youtubeVideoUrl",
24 | createdAt: new Date("2024-10-20").toISOString(),
25 | updatedAt: new Date("2024-10-20").toISOString(),
26 | primaryMuscles: [{id: "102"}],
27 | secondaryMuscles: [],
28 | },];
29 |
30 | async findAll(sort?: ExerciceSortEnum,): Promise {
31 | return this.exercices;
32 | }
33 |
34 | async findById(exerciceId: string): Promise {
35 | return this.exercices.find((ex) => ex.id === exerciceId) || null;
36 | }
37 |
38 | async create(exercice: CreateExerciceCommand): Promise {
39 | //todo fix type
40 | this.exercices = [...this.exercices, {
41 | id: "3",
42 | title: exercice.title,
43 | description: exercice.description,
44 | image: exercice.image,
45 | youtubeVideoUrl: exercice.youtubeVideoUrl,
46 | createdAt: new Date().toISOString(),
47 | updatedAt: new Date().toISOString(),
48 | primaryMuscles: exercice.primaryMuscles,
49 | secondaryMuscles: exercice.secondaryMuscles,
50 | },];
51 | }
52 |
53 | async update(exerciceId: string, updateExerciceDto: UpdateExerciceCommand,): Promise {
54 | const exerciceToUpdate = this.exercices.find((ex) => ex.id === exerciceId);
55 |
56 | const exerciceUpdated = {
57 | ...exerciceToUpdate,
58 | title: updateExerciceDto.title,
59 | description: updateExerciceDto.description,
60 | image: updateExerciceDto.image,
61 | youtubeVideoUrl: updateExerciceDto.youtubeVideoUrl,
62 | primaryMuscles: updateExerciceDto.primaryMuscles,
63 | secondaryMuscles: updateExerciceDto.secondaryMuscles,
64 | updatedAt: new Date().toISOString(),
65 | };
66 | //todo fix type
67 | this.exercices = this.exercices.map((exercice) => exercice.id === exerciceId ? exerciceUpdated : exercice,);
68 | }
69 |
70 | async deleteById(id: string): Promise {
71 | this.exercices = this.exercices.filter((ex) => ex.id !== id);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/exercice/features/shared/test/utils/create-test-store-with-exercices.ts:
--------------------------------------------------------------------------------
1 | import {createTestStore} from "@/src/shared/application/test/test.store";
2 | import {ExerciceSuccessRepositoryFake} from "@/src/exercice/features/shared/test/exercice-success.repository.fake";
3 |
4 | export const createTestStoreWithExercices = async () => {
5 | const exerciceSuccessRepository = new ExerciceSuccessRepositoryFake();
6 | const exercices = (await exerciceSuccessRepository.findAll());
7 | return createTestStore({
8 | exercices: {
9 | list: {
10 | data: exercices,
11 | },
12 | },
13 | });
14 | };
15 |
--------------------------------------------------------------------------------
/src/exercice/features/update-exercice/update-exercice.events.ts:
--------------------------------------------------------------------------------
1 | import {createAction} from "@reduxjs/toolkit";
2 |
3 | export const exerciceUpdateStarted = createAction("EXERCICE_UPDATE_STARTED");
4 | export const exerciceUpdated = createAction("EXERCICE_UPDATED");
5 | export const exerciceUpdateFailed = createAction("EXERCICE_UPDATE_FAILED", (errorMessage: string) => ({
6 | payload: errorMessage,
7 | }));
8 |
--------------------------------------------------------------------------------
/src/exercice/features/update-exercice/update-exercice.reducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | exerciceUpdated, exerciceUpdateFailed, exerciceUpdateStarted,
3 | } from "@/src/exercice/features/update-exercice/update-exercice.events";
4 | import {createReducer} from "@reduxjs/toolkit";
5 | import {
6 | updateExerciceInitialState, UpdatExerciceStatus
7 | } from "@/src/exercice/features/update-exercice/update-exercice.state.model";
8 |
9 | const updateExerciceReducer = createReducer(updateExerciceInitialState, (builder) => {
10 | builder
11 | .addCase(exerciceUpdateStarted, (state) => {
12 | state.status = UpdatExerciceStatus.LOADING;
13 | })
14 | .addCase(exerciceUpdated, (state) => {
15 | state.status = UpdatExerciceStatus.SUCCESS;
16 | })
17 | .addCase(exerciceUpdateFailed, (state, action) => {
18 | state.status = UpdatExerciceStatus.ERROR;
19 | state.error = action.payload;
20 | });
21 | });
22 |
23 | export default updateExerciceReducer;
--------------------------------------------------------------------------------
/src/exercice/features/update-exercice/update-exercice.state-machine.md:
--------------------------------------------------------------------------------
1 | ```mermaid
2 | ---
3 | title: Update Exercice State
4 | ---
5 | flowchart TD
6 |
7 | A[
8 | Idle
9 |
10 | Status: idle
11 | Error: null
12 |
13 | Notifications: n
14 |
15 | List Exercices Data: n
16 | ]
17 |
18 | B[
19 | Loading
20 |
21 | Status: loading
22 | Error: null
23 |
24 | Notifications: n
25 |
26 | List Exercices Data: n
27 | ]
28 |
29 | C[
30 | Error
31 |
32 | Status: error
33 | Error: error message
34 |
35 | Notification: n + 1 error
36 |
37 | List Exercices Data: n
38 | ]
39 |
40 | D[
41 | Success
42 |
43 | Status: success
44 | Error: null
45 |
46 | Notification: n + 1 success
47 | ]
48 |
49 | E[
50 | Success
51 |
52 | ...
53 | Data: n w/ updated exercice
54 | ]
55 |
56 | subgraph Update exercice
57 | A -->|Exercice updating started|B
58 | B -->|Exercice updating failed|C
59 | B -->|Exercice updating success|D
60 | end
61 |
62 | subgraph List Exercices
63 | D -->|...|E
64 | end
65 | ```
66 |
--------------------------------------------------------------------------------
/src/exercice/features/update-exercice/update-exercice.state.model.ts:
--------------------------------------------------------------------------------
1 | import {RootState} from "@/src/shared/application/root.state";
2 |
3 | export type UpdateExerciceStateModel = {
4 | status: UpdatExerciceStatus;
5 | error: string | null;
6 | };
7 |
8 | export enum UpdatExerciceStatus {
9 | IDLE = "idle", LOADING = "loading", SUCCESS = "success", ERROR = "error",
10 | }
11 |
12 | export const updateExerciceInitialState: UpdateExerciceStateModel = {
13 | status: UpdatExerciceStatus.IDLE,
14 | error: null,
15 | };
16 | export const getExerciceUpdateStatus = (state: RootState) => state.exercices.update.status;
17 |
18 | export const getExerciceUpdateError = (state: RootState) => state.exercices.update.error;
--------------------------------------------------------------------------------
/src/exercice/features/update-exercice/update-exercice.usecase.spec.ts:
--------------------------------------------------------------------------------
1 | import {AppStore} from "@/src/shared/application/root.store";
2 | import {ExerciceLoadingRepositoryFake} from "@/src/exercice/features/shared/test/exercice-loading.repository.fake";
3 | import {ExerciceErrorRepositoryFake} from "@/src/exercice/features/shared/test/exercice-error.repository.fake";
4 | import {ExerciceSuccessRepositoryFake} from "@/src/exercice/features/shared/test/exercice-success.repository.fake";
5 | import {createTestStoreWithExercices} from "@/src/exercice/features/shared/test/utils/create-test-store-with-exercices";
6 | import {
7 | UpdateExerciceCommand, updateExerciceUseCase
8 | } from "@/src/exercice/features/update-exercice/update-exercice.usecase";
9 | import {Exercice, ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.model.type";
10 | import {getExercicesListData} from "@/src/exercice/features/list-exercices/list-exercices.state.model";
11 | import {
12 | getExerciceUpdateError, getExerciceUpdateStatus
13 | } from "@/src/exercice/features/update-exercice/update-exercice.state.model";
14 | import {getNotificationsList} from "@/src/notification/features/shared/notification.state.model";
15 |
16 | describe("As a user i want to update an exercice", () => {
17 | let testStore: AppStore;
18 | let exercices: Exercice[] | ExercicesSortedByMuscle[];
19 | let exerciceIdToUpdate: string;
20 |
21 | const updateExerciceCommand: UpdateExerciceCommand = {
22 | title: "Romanian Deadlift 2",
23 | description: "update The Romanian deadlift is a variation of the conventional deadlift that targets the posterior chain, including the hamstrings, glutes, and lower back.",
24 | image: "https://wger.de/media/exercise-images/89/Romanian-deadlift-1.png",
25 | youtubeVideoUrl: "https://www.youtube.com/watch?v=jEy_czb3RKA",
26 | primaryMuscles: [{id: "205"}],
27 | secondaryMuscles: [{id: "205"}],
28 | };
29 |
30 | describe("Given two exercices are created", () => {
31 | beforeAll(async () => {
32 | testStore = await createTestStoreWithExercices();
33 | exercices = getExercicesListData(testStore.getState());
34 | exerciceIdToUpdate = exercices[0].id;
35 | });
36 |
37 | describe("When the exercice update has not started", () => {
38 |
39 | test("Then the status should be idle", async () => {
40 | expect(getExerciceUpdateStatus(testStore.getState())).toBe("idle");
41 | });
42 |
43 | test("Then there should be no error", async () => {
44 | expect(getExerciceUpdateError(testStore.getState())).toBe(null);
45 | });
46 |
47 | test("Then the exercices list should contains the original exercices", async () => {
48 | expect(getExercicesListData(testStore.getState())).toEqual(exercices);
49 | });
50 |
51 | });
52 | });
53 |
54 | describe("Given two exercices are created", () => {
55 | beforeAll(async () => {
56 | testStore = await createTestStoreWithExercices();
57 | exercices = getExercicesListData(testStore.getState());
58 | exerciceIdToUpdate = exercices[0].id;
59 | });
60 |
61 | describe("When the exercice update starts", () => {
62 | beforeAll(() => {
63 | updateExerciceUseCase(exerciceIdToUpdate, updateExerciceCommand)(
64 | testStore.dispatch, testStore.getState,
65 | {
66 | exerciceRepository: new ExerciceLoadingRepositoryFake(),
67 | },
68 | );
69 | });
70 |
71 | test("Then the status should be loading", async () => {
72 | expect(getExerciceUpdateStatus(testStore.getState())).toBe("loading");
73 | });
74 |
75 | test("Then there should be no error", async () => {
76 | expect(getExerciceUpdateError(testStore.getState())).toBe(null);
77 | });
78 |
79 | test("Then the exercices list should contains the original exercices", async () => {
80 | expect(getExercicesListData(testStore.getState())).toEqual(exercices);
81 | });
82 | });
83 | });
84 |
85 | describe("Given two exercices are created", () => {
86 | beforeAll(async () => {
87 | testStore = await createTestStoreWithExercices();
88 | exercices = getExercicesListData(testStore.getState());
89 | exerciceIdToUpdate = exercices[0].id;
90 | });
91 |
92 | describe("When the exercice is updated successfully", () => {
93 | beforeAll(async () => {
94 | await updateExerciceUseCase(exerciceIdToUpdate, updateExerciceCommand)(
95 | testStore.dispatch,
96 | testStore.getState, {
97 | exerciceRepository: new ExerciceSuccessRepositoryFake(),
98 | },
99 | );
100 | });
101 |
102 | test("Then the status should be loading", async () => {
103 | expect(getExerciceUpdateStatus(testStore.getState())).toBe("success");
104 | });
105 |
106 | test("Then there should be no error", async () => {
107 | expect(getExerciceUpdateError(testStore.getState())).toBe(null);
108 | });
109 |
110 | test("Then the exercice list data should contain the updated exercice", async () => {
111 | expect(getExercicesListData(testStore.getState())[0].id).toEqual(exerciceIdToUpdate);
112 | expect(getExercicesListData(testStore.getState())[0].title).toEqual(updateExerciceCommand.title);
113 | });
114 |
115 | test("Then it should set a success notification", async () => {
116 | const updateSuccessNotification = getNotificationsList(testStore.getState()).find(
117 | (notification) => notification.message === "Exercice mise à jour réussie",);
118 |
119 | expect(updateSuccessNotification).not.toBeUndefined();
120 | });
121 | });
122 | });
123 |
124 | describe("Given two exercices are created", () => {
125 | beforeAll(async () => {
126 | testStore = await createTestStoreWithExercices();
127 | exercices = getExercicesListData(testStore.getState());
128 | exerciceIdToUpdate = exercices[0].id;
129 | });
130 |
131 | describe("When the exercice update fails", () => {
132 | beforeAll(async () => {
133 | await updateExerciceUseCase(exerciceIdToUpdate, updateExerciceCommand)(
134 | testStore.dispatch,
135 | testStore.getState, {
136 | exerciceRepository: new ExerciceErrorRepositoryFake(),
137 | },
138 | );
139 | });
140 |
141 | test("Then the status should be error", async () => {
142 | expect(getExerciceUpdateStatus(testStore.getState())).toBe("error");
143 | });
144 |
145 | test("Then there should be an error", async () => {
146 | expect(getExerciceUpdateError(testStore.getState())).toBe("Exercice maj échouée");
147 | });
148 |
149 | test("Then the exercices list should contains the original exercices", async () => {
150 | expect(getExercicesListData(testStore.getState())).toEqual(exercices);
151 | });
152 |
153 | test("Then it should set an error notification", async () => {
154 | const updateErrorNotification = getNotificationsList(testStore.getState()).find(
155 | (notification) => notification.message === "Exercice maj échouée",);
156 |
157 | expect(updateErrorNotification).not.toBeUndefined();
158 | });
159 | });
160 | });
161 | });
162 |
--------------------------------------------------------------------------------
/src/exercice/features/update-exercice/update-exercice.usecase.ts:
--------------------------------------------------------------------------------
1 | import {Thunk} from "@/src/shared/application/thunk.type";
2 | import {
3 | exercicesLoaded, exercicesLoadingFailed, exercicesLoadingStarted,
4 | } from "@/src/exercice/features/list-exercices/list-exercices.events";
5 | import {
6 | exerciceUpdated, exerciceUpdateFailed, exerciceUpdateStarted,
7 | } from "@/src/exercice/features/update-exercice/update-exercice.events";
8 | import {Dispatch} from "@reduxjs/toolkit";
9 | import {ExerciceRepositoryInterface} from "@/src/exercice/features/shared/exercice.repository.interface";
10 |
11 | export type UpdateExerciceCommand = {
12 | title: string;
13 | description: string | null;
14 | image: string | null;
15 | youtubeVideoUrl: string | null;
16 | primaryMuscles: [{
17 | id: string
18 | }];
19 | secondaryMuscles: [{
20 | id: string
21 | }];
22 | };
23 | export const updateExerciceUseCase = (
24 | exerciceId: string, updateExercice: UpdateExerciceCommand): Thunk => async (dispatch, _, {exerciceRepository}) => {
25 | dispatch(exerciceUpdateStarted());
26 | try {
27 | await exerciceRepository.update(exerciceId, updateExercice);
28 | dispatch(exerciceUpdated());
29 |
30 | fetchExercices(dispatch, exerciceRepository);
31 |
32 | } catch (error) {
33 | const errorMessage = error instanceof Error ? error.message : "An error occurred while updating the exercice.";
34 | dispatch(exerciceUpdateFailed(errorMessage));
35 | }
36 | };
37 |
38 | const fetchExercices = async (dispatch: Dispatch, exerciceRepository: ExerciceRepositoryInterface) => {
39 | dispatch(exercicesLoadingStarted());
40 |
41 | try {
42 | const exercices = await exerciceRepository.findAll();
43 |
44 | dispatch(exercicesLoaded(exercices));
45 | } catch (error: any) {
46 | const errorMessage = error instanceof Error ? error.message : "Exercice création échouée";
47 | dispatch(exercicesLoadingFailed(errorMessage));
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/exercice/ui/create-exercice/CreateExercice.tsx:
--------------------------------------------------------------------------------
1 | import {useCreateExerciceViewModel} from "@/src/exercice/ui/create-exercice/create-exercice.view-model";
2 | import {Button, Image, StyleSheet, Text, TextInput, TouchableOpacity, View,} from "react-native";
3 |
4 | import {CreateExerciceStatus} from "@/src/exercice/features/create-exercice/create-exercice.state.model";
5 |
6 | export default function CreateExercice() {
7 | const {
8 | title,
9 | setTitle,
10 | description,
11 | setDescription,
12 | image,
13 | youtubeVideoUrl,
14 | setYoutubeVideoUrl,
15 | handleImagePick,
16 | handleSubmit,
17 | createExerciceStatus
18 | } = useCreateExerciceViewModel();
19 |
20 | if (createExerciceStatus === CreateExerciceStatus.LOADING) {
21 | return (
22 | Création de l'exercice en cours...
23 | );
24 | }
25 |
26 | return (
27 | Créer un exercice
28 |
29 |
36 |
37 |
45 |
46 |
47 |
48 | {image ? "Changer l'image" : "Ajouter une image"}
49 |
50 |
51 | {image && }
52 |
53 |
60 |
61 |
62 | );
63 | }
64 |
65 | const styles = StyleSheet.create({
66 | container: {
67 | flex: 1,
68 | backgroundColor: "#25292e",
69 | padding: 20,
70 | },
71 | header: {
72 | fontSize: 24,
73 | color: "#fff",
74 | textAlign: "center",
75 | marginBottom: 20,
76 | },
77 | input: {
78 | backgroundColor: "#333",
79 | color: "#fff",
80 | paddingHorizontal: 15,
81 | paddingVertical: 10,
82 | borderRadius: 5,
83 | marginBottom: 15,
84 | },
85 | textArea: {
86 | height: 80,
87 | },
88 | imageButton: {
89 | backgroundColor: "#ffd33d",
90 | paddingVertical: 10,
91 | borderRadius: 5,
92 | alignItems: "center",
93 | marginBottom: 10,
94 | },
95 | imageButtonText: {
96 | color: "#25292e",
97 | },
98 | imagePreview: {
99 | width: 100,
100 | height: 100,
101 | borderRadius: 5,
102 | marginVertical: 10,
103 | alignSelf: "center",
104 | },
105 | });
106 |
--------------------------------------------------------------------------------
/src/exercice/ui/create-exercice/create-exercice.view-model.ts:
--------------------------------------------------------------------------------
1 | import {useState} from "react";
2 | import * as ImagePicker from "expo-image-picker";
3 | import {useRouter} from "expo-router";
4 | import {useDispatch, useSelector} from "react-redux";
5 | import {createExerciceUseCase} from "@/src/exercice/features/create-exercice/create-exercice.use-case";
6 |
7 | import {
8 | CreateExerciceStatus, getExerciceCreateStatus
9 | } from "@/src/exercice/features/create-exercice/create-exercice.state.model";
10 |
11 | export const useCreateExerciceViewModel = (): {
12 | title: string;
13 | setTitle: (title: string) => void;
14 | description: string;
15 | setDescription: (description: string) => void;
16 | image: string | null;
17 | setImage: (image: string | null) => void;
18 | youtubeVideoUrl: string;
19 | setYoutubeVideoUrl: (youtubeVideoUrl: string) => void;
20 | handleImagePick: () => void;
21 | handleSubmit: () => void;
22 | createExerciceStatus: CreateExerciceStatus;
23 | } => {
24 | const [title, setTitle] = useState("");
25 | const [description, setDescription] = useState("");
26 | const [image, setImage] = useState(null);
27 | const [youtubeVideoUrl, setYoutubeVideoUrl] = useState("");
28 |
29 | const handleImagePick = async () => {
30 | let result = await ImagePicker.launchImageLibraryAsync({
31 | mediaTypes: ImagePicker.MediaTypeOptions.Images,
32 | allowsEditing: true,
33 | aspect: [4, 3],
34 | quality: 1,
35 | });
36 |
37 | if (!result.canceled) {
38 | console.log(result);
39 | //setImage(result.uri);
40 | }
41 | };
42 |
43 | const router = useRouter();
44 |
45 | const dispatch = useDispatch();
46 |
47 | const handleSubmit = async () => {
48 | dispatch(createExerciceUseCase({
49 | title,
50 | description,
51 | image,
52 | youtubeVideoUrl,
53 | primaryMuscles: [{id: "101"}],
54 | secondaryMuscles: [],
55 | }),);
56 | router.push("/exercices");
57 | };
58 |
59 | const createExerciceStatus = useSelector(getExerciceCreateStatus);
60 |
61 | return {
62 | title,
63 | setTitle,
64 | description,
65 | setDescription,
66 | image,
67 | setImage,
68 | youtubeVideoUrl,
69 | setYoutubeVideoUrl,
70 | handleImagePick,
71 | handleSubmit,
72 | createExerciceStatus
73 | };
74 | };
75 |
--------------------------------------------------------------------------------
/src/exercice/ui/list-all-exercices/ListAllExercices.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {FlatList, StyleSheet, Text, TouchableOpacity, View,} from "react-native";
3 | import {Ionicons} from "@expo/vector-icons";
4 | import {uselistAllExercices} from "@/src/exercice/ui/list-all-exercices/ListAllExercices.view-model";
5 | import DisplayExercice from "@/src/exercice/ui/list-all-exercices/display-exercice/DisplayExercice";
6 | import {ExercicesSortedByMuscle} from "@/src/exercice/features/shared/exercice.state";
7 | import {ListExercicesStatus} from "@/src/exercice/features/list-exercices/list-exercices.state.model";
8 |
9 | export default function ListAllExercices() {
10 | const {
11 | exercices,
12 | listExercicesStatus,
13 | navigateToCreateExercice
14 | } = uselistAllExercices();
15 |
16 | if (listExercicesStatus === ListExercicesStatus.LOADING) {
17 | return Loading...;
18 | }
19 |
20 | const renderMuscle = (muscle: ExercicesSortedByMuscle) => (
21 | {muscle.title}
22 | {muscle.exercises && muscle.exercises.length > 0 && ( }
25 | keyExtractor={(item) => item.id}
26 | />)}
27 | {muscle.children && muscle.children.length > 0 && (
28 | {muscle.children.map((subMuscle) => renderMuscle(subMuscle))}
29 | )}
30 | );
31 |
32 | return (
33 | Les exercices
34 | renderMuscle(item)}
37 | keyExtractor={(item) => item.id}
38 | showsVerticalScrollIndicator={false}
39 | contentContainerStyle={styles.listContentContainer}
40 | />
41 |
45 |
46 |
47 | );
48 | }
49 |
50 | const styles = StyleSheet.create({
51 | container: {
52 | flex: 1,
53 | backgroundColor: "#25292e",
54 | padding: 20,
55 | },
56 | headerText: {
57 | fontSize: 24,
58 | color: "#fff",
59 | marginBottom: 20,
60 | alignSelf: "center",
61 | },
62 | listContentContainer: {
63 | paddingBottom: 100,
64 | },
65 | muscleContainer: {
66 | marginBottom: 20,
67 | },
68 | muscleTitle: {
69 | fontSize: 20,
70 | color: "#ffd33d",
71 | marginBottom: 10,
72 | },
73 | subMuscleContainer: {
74 | paddingLeft: 20,
75 | },
76 | roundButton: {
77 | width: 60,
78 | height: 60,
79 | backgroundColor: "#ffd33d",
80 | borderRadius: 30,
81 | justifyContent: "center",
82 | alignItems: "center",
83 | position: "absolute",
84 | bottom: 30,
85 | right: 30,
86 | },
87 | });
88 |
--------------------------------------------------------------------------------
/src/exercice/ui/list-all-exercices/ListAllExercices.view-model.ts:
--------------------------------------------------------------------------------
1 | import {useDispatch, useSelector} from "react-redux";
2 | import {useEffect} from "react";
3 | import {useRouter} from "expo-router";
4 | import {Exercice, ExercicesSortedByMuscle,} from "@/src/exercice/features/shared/exercice.model.type";
5 | import {listExercicesUseCase} from "@/src/exercice/features/list-exercices/list-exercices.use-case";
6 | import {ExerciceSortEnum} from "@/src/exercice/features/list-exercices/list-exercices-sort.enum";
7 | import {
8 | getExercicesListData, getExercicesListStatus, ListExercicesStatus
9 | } from "@/src/exercice/features/list-exercices/list-exercices.state.model";
10 |
11 | export const uselistAllExercices = (): {
12 | exercices: Exercice[] | ExercicesSortedByMuscle[];
13 | isLoading: boolean;
14 | navigateToCreateExercice: () => void;
15 | listExercicesStatus: ListExercicesStatus
16 | } => {
17 | const dispatch = useDispatch();
18 | const exercices = useSelector(getExercicesListData);
19 | const listExercicesStatus = useSelector(getExercicesListStatus);
20 |
21 | useEffect(() => {
22 | //todo : fix type
23 | dispatch(listExercicesUseCase(ExerciceSortEnum.MUSCLE_GROUP));
24 | }, [dispatch]);
25 |
26 | const router = useRouter();
27 | const navigateToCreateExercice = () => {
28 | router.push("exercices/create-exercice");
29 | };
30 |
31 | return {
32 | exercices,
33 | listExercicesStatus,
34 | navigateToCreateExercice
35 | };
36 | };
37 |
--------------------------------------------------------------------------------
/src/exercice/ui/list-all-exercices/display-exercice/DisplayExercice.tsx:
--------------------------------------------------------------------------------
1 | import {Swipeable} from "react-native-gesture-handler";
2 | import {Image, StyleSheet, Text, View} from "react-native";
3 | import ExerciceActions from "@/src/exercice/ui/list-all-exercices/display-exercice/exercice-actions/ExerciceActions";
4 | import {Exercice} from "@/src/exercice/features/shared/exercice.model.type";
5 |
6 | type DisplayExerciceProps = {
7 | item: Exercice;
8 | };
9 |
10 | const DisplayExercice: React.FC = ({
11 | item: exercice,
12 | }) => {
13 | return (
14 | }
17 | containerStyle={styles.swipeableContainer}
18 | >
19 |
20 | {exercice.image ? (
21 |
22 | ) : (
23 |
24 | )}
25 | {exercice.title}
26 |
27 |
28 | );
29 | };
30 |
31 | export default DisplayExercice;
32 |
33 | const styles = StyleSheet.create({
34 | swipeableContainer: {
35 | flex: 1,
36 | },
37 | exerciceItem: {
38 | flexDirection: "row",
39 | alignItems: "center",
40 | width: "100%",
41 | marginBottom: 5,
42 | backgroundColor: "#444",
43 | padding: 5,
44 | borderRadius: 5,
45 | },
46 | image: {
47 | width: 30,
48 | height: 30,
49 | borderRadius: 15,
50 | marginRight: 10,
51 | },
52 | imagePlaceholder: {
53 | width: 30,
54 | height: 30,
55 | borderRadius: 15,
56 | backgroundColor: "#cccccc",
57 | marginRight: 10,
58 | },
59 | exerciceText: {
60 | fontSize: 16,
61 | color: "#fff",
62 | textAlign: "left",
63 | flex: 1,
64 | },
65 | });
66 |
--------------------------------------------------------------------------------
/src/exercice/ui/list-all-exercices/display-exercice/exercice-actions/ExerciceActions.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {StyleSheet, Text, TouchableOpacity, View} from "react-native";
3 | import {Ionicons} from "@expo/vector-icons";
4 | import {
5 | useExerciceActionsViewModel
6 | } from "@/src/exercice/ui/list-all-exercices/display-exercice/exercice-actions/exercice-actions.view-model";
7 |
8 | import {DeleteExerciceStatus} from "@/src/exercice/features/delete-exercice/delete-exercice.state.model";
9 |
10 | type ExerciceActionsProps = {
11 | exerciceId: string;
12 | };
13 |
14 | const ExerciceActions: React.FC = ({exerciceId}) => {
15 | const {
16 | handleEdit,
17 | handleDelete,
18 | exerciceDeleteStatus
19 | } = useExerciceActionsViewModel();
20 |
21 | return (
22 | handleEdit(exerciceId)}
25 | >
26 |
27 | Modifier
28 |
29 |
30 | handleDelete(exerciceId)}
33 | disabled={exerciceDeleteStatus === DeleteExerciceStatus.LOADING}
34 | >
35 |
36 | Supprimer
37 |
38 | );
39 | };
40 |
41 | const styles = StyleSheet.create({
42 | rightActions: {
43 | flexDirection: "row",
44 | justifyContent: "flex-end",
45 | alignItems: "center",
46 | backgroundColor: "#ff3b30",
47 | borderRadius: 10,
48 | marginBottom: 10,
49 | },
50 | actionButton: {
51 | width: 75,
52 | height: "100%",
53 | justifyContent: "center",
54 | alignItems: "center",
55 | },
56 | actionText: {
57 | color: "white",
58 | fontSize: 12,
59 | marginTop: 5,
60 | },
61 | });
62 |
63 | export default ExerciceActions;
64 |
--------------------------------------------------------------------------------
/src/exercice/ui/list-all-exercices/display-exercice/exercice-actions/exercice-actions.view-model.ts:
--------------------------------------------------------------------------------
1 | import {deleteExerciceUseCase} from "@/src/exercice/features/delete-exercice/delete-exercice.use-case";
2 | import {useRouter} from "expo-router";
3 | import {useDispatch, useSelector} from "react-redux";
4 |
5 | import {
6 | DeleteExerciceStatus, getDeleteExerciceStatus
7 | } from "@/src/exercice/features/delete-exercice/delete-exercice.state.model";
8 |
9 | export const useExerciceActionsViewModel = (): {
10 | handleEdit: (exerciceId: string) => void;
11 | handleDelete: (exerciceId: string) => void;
12 | exerciceDeleteStatus: DeleteExerciceStatus;
13 | } => {
14 | const router = useRouter();
15 | const dispatch = useDispatch();
16 | const exerciceDeleteStatus = useSelector(getDeleteExerciceStatus);
17 |
18 | const handleEdit = (exerciceId: string) => {
19 | router.push(`/exercices/update/${exerciceId}`);
20 | };
21 |
22 | const handleDelete = async (exerciceId: string) => {
23 | dispatch(deleteExerciceUseCase(exerciceId));
24 | };
25 |
26 | return {
27 | handleEdit,
28 | handleDelete
29 | };
30 | };
31 |
--------------------------------------------------------------------------------
/src/exercice/ui/update-exercice/UpdateExercice.tsx:
--------------------------------------------------------------------------------
1 | import {useUpdateExerciceViewModel} from "@/src/exercice/ui/update-exercice/update-exercice.view-model";
2 | import {Button, Image, StyleSheet, Text, TextInput, TouchableOpacity, View,} from "react-native";
3 |
4 | export default function UpdateExercice() {
5 | const {
6 | title,
7 | setTitle,
8 | description,
9 | setDescription,
10 | image,
11 | youtubeVideoUrl,
12 | setYoutubeVideoUrl,
13 | handleImagePick,
14 | handleSubmit,
15 | exerciceByIdStatus
16 | } = useUpdateExerciceViewModel();
17 |
18 | if (exerciceByIdStatus === "loading") {
19 | return (
20 | Chargement...
21 | );
22 | }
23 |
24 | return (
25 | Modifier l'exercice
26 |
27 |
34 |
35 |
43 |
44 |
45 |
46 | {image ? "Changer l'image" : "Ajouter une image"}
47 |
48 |
49 | {image && }
50 |
51 |
58 |
59 |
64 | );
65 | }
66 |
67 | const styles = StyleSheet.create({
68 | container: {
69 | flex: 1,
70 | backgroundColor: "#25292e",
71 | padding: 20,
72 | },
73 | header: {
74 | fontSize: 24,
75 | color: "#fff",
76 | textAlign: "center",
77 | marginBottom: 20,
78 | },
79 | input: {
80 | backgroundColor: "#333",
81 | color: "#fff",
82 | paddingHorizontal: 15,
83 | paddingVertical: 10,
84 | borderRadius: 5,
85 | marginBottom: 15,
86 | },
87 | textArea: {
88 | height: 80,
89 | },
90 | imageButton: {
91 | backgroundColor: "#ffd33d",
92 | paddingVertical: 10,
93 | borderRadius: 5,
94 | alignItems: "center",
95 | marginBottom: 10,
96 | },
97 | imageButtonText: {
98 | color: "#25292e",
99 | },
100 | imagePreview: {
101 | width: 100,
102 | height: 100,
103 | borderRadius: 5,
104 | marginVertical: 10,
105 | alignSelf: "center",
106 | },
107 | });
108 |
--------------------------------------------------------------------------------
/src/exercice/ui/update-exercice/update-exercice.view-model.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from "react";
2 | import * as ImagePicker from "expo-image-picker";
3 | import {useLocalSearchParams, useRouter} from "expo-router";
4 | import {useDispatch, useSelector} from "react-redux";
5 | import {getExerciceByIdUseCase} from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.usecase";
6 | import {updateExerciceUseCase} from "@/src/exercice/features/update-exercice/update-exercice.usecase";
7 | import {
8 | getExerciceByIdData, getExerciceByIdStatus
9 | } from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.state.model";
10 |
11 | export const useUpdateExerciceViewModel = (): {
12 | title: string;
13 | setTitle: (title: string) => void;
14 | description: string;
15 | setDescription: (description: string) => void;
16 | image: string | null;
17 | setImage: (image: string | null) => void;
18 | youtubeVideoUrl: string;
19 | setYoutubeVideoUrl: (youtubeVideoUrl: string) => void;
20 | handleImagePick: () => void;
21 | handleSubmit: () => void;
22 | exerciceByIdStatus: string;
23 | } => {
24 | const {id} = useLocalSearchParams();
25 | const dispatch = useDispatch();
26 |
27 | const [title, setTitle] = useState("");
28 | const [description, setDescription] = useState("");
29 | const [image, setImage] = useState(null);
30 | const [youtubeVideoUrl, setYoutubeVideoUrl] = useState("");
31 |
32 | useEffect(() => {
33 | dispatch(getExerciceByIdUseCase(id));
34 | }, [id]);
35 |
36 | const exercice = useSelector(getExerciceByIdData);
37 |
38 | useEffect(() => {
39 | if (exercice) {
40 | setTitle(exercice.title);
41 | setDescription(exercice.description);
42 | setImage(exercice.image);
43 | setYoutubeVideoUrl(exercice.youtubeVideoUrl);
44 | }
45 |
46 | }, [exercice]);
47 |
48 | const handleImagePick = async () => {
49 | let result = await ImagePicker.launchImageLibraryAsync({
50 | mediaTypes: ImagePicker.MediaTypeOptions.Images,
51 | allowsEditing: true,
52 | aspect: [4, 3],
53 | quality: 1,
54 | });
55 |
56 | if (!result.canceled) {
57 | // setImage(result.uri);
58 | }
59 | };
60 |
61 | const router = useRouter();
62 |
63 | const handleSubmit = async () => {
64 | dispatch(updateExerciceUseCase(id, {
65 | title,
66 | description,
67 | image,
68 | youtubeVideoUrl,
69 | }));
70 |
71 | router.push("exercices");
72 | };
73 |
74 | const exerciceByIdStatus = useSelector(getExerciceByIdStatus);
75 |
76 | return {
77 | title,
78 | setTitle,
79 | description,
80 | setDescription,
81 | image,
82 | setImage,
83 | youtubeVideoUrl,
84 | setYoutubeVideoUrl,
85 | handleImagePick,
86 | handleSubmit,
87 | exerciceByIdStatus
88 | };
89 | };
90 |
--------------------------------------------------------------------------------
/src/muscle/features/shared/muscle.model.type.ts:
--------------------------------------------------------------------------------
1 | export type Muscle = {
2 | id: string;
3 | title: string;
4 | //parent?: MuscleDto | null;
5 | children: Muscle[];
6 | };
7 |
--------------------------------------------------------------------------------
/src/muscle/features/shared/muscle.repository.interface.ts:
--------------------------------------------------------------------------------
1 | import { Muscle } from "@/src/muscle/features/shared/muscle.model.type";
2 |
3 | export interface MuscleRepositoryInterface {
4 | findAll(): Promise;
5 | }
6 |
--------------------------------------------------------------------------------
/src/muscle/shared/infrastructure/muscle.repository.in-memory.ts:
--------------------------------------------------------------------------------
1 | import { MuscleRepositoryInterface } from "@/src/muscle/features/shared/muscle.repository.interface";
2 | import { Muscle } from "@/src/muscle/features/shared/muscle.model.type";
3 |
4 | export class MuscleRepositoryInMemory implements MuscleRepositoryInterface {
5 | private muscles: Muscle[] = [
6 | {
7 | id: "1",
8 | title: "Chest",
9 | children: [
10 | {
11 | id: "101",
12 | title: "Upper Chest",
13 | children: [],
14 | },
15 | {
16 | id: "102",
17 | title: "Middle Chest",
18 | children: [],
19 | },
20 | {
21 | id: "103",
22 | title: "Lower Chest",
23 | children: [],
24 | },
25 | ],
26 | },
27 | {
28 | id: "2",
29 | title: "Back",
30 | children: [
31 | {
32 | id: "201",
33 | title: "Traps",
34 | children: [],
35 | },
36 | {
37 | id: "202",
38 | title: "Lats",
39 | children: [],
40 | },
41 | {
42 | id: "203",
43 | title: "Rhomboids",
44 | children: [],
45 | },
46 | ],
47 | },
48 | {
49 | id: "3",
50 | title: "Legs",
51 | children: [
52 | {
53 | id: "301",
54 | title: "Quads",
55 | children: [],
56 | },
57 | {
58 | id: "302",
59 | title: "Hamstrings",
60 | children: [],
61 | },
62 | {
63 | id: "303",
64 | title: "Calves",
65 | children: [
66 | {
67 | id: "30301",
68 | title: "Soleus",
69 | children: [],
70 | },
71 | {
72 | id: "30302",
73 | title: "Gastrocnemius",
74 | children: [],
75 | },
76 | ],
77 | },
78 | ],
79 | },
80 | {
81 | id: "4",
82 | title: "Arms",
83 | children: [
84 | {
85 | id: "401",
86 | title: "Biceps",
87 | children: [],
88 | },
89 | {
90 | id: "402",
91 | title: "Triceps",
92 | children: [],
93 | },
94 | ],
95 | },
96 | ];
97 |
98 | async findAll(): Promise {
99 | return this.muscles.map((muscle) => ({
100 | ...muscle,
101 | }));
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/notification/features/add-notification/add-notification.reducer.ts:
--------------------------------------------------------------------------------
1 | import {exerciceCreated, exerciceCreationFailed,} from "@/src/exercice/features/create-exercice/create-exercice.events";
2 | import {exerciceDeleted, exerciceDeletionFailed,} from "@/src/exercice/features/delete-exercice/delete-exercice.events";
3 | import {
4 | exerciceLoaded, exerciceLoadingFailed,
5 | } from "@/src/exercice/features/get-exercice-by-id/get-exercice-by-id.events";
6 | import {exercicesLoadingFailed,} from "@/src/exercice/features/list-exercices/list-exercices.events";
7 | import {exerciceUpdated, exerciceUpdateFailed,} from "@/src/exercice/features/update-exercice/update-exercice.events";
8 | import {createReducer} from "@reduxjs/toolkit";
9 | import {notificationsInitialState, NotificationType} from "@/src/notification/features/shared/notification.state.model";
10 | import {generateNotification} from "@/src/notification/features/add-notification/notification-generator.service";
11 |
12 | const addNotificationReducer = createReducer(notificationsInitialState, (builder) => {
13 | builder
14 | .addCase(exerciceCreated, (state) => {
15 | state.list.push(generateNotification("Exercice créé", NotificationType.SUCCESS));
16 | })
17 | .addCase(exerciceCreationFailed, (state, action) => {
18 | state.list.push(generateNotification(action.payload, NotificationType.ERROR));
19 | })
20 | .addCase(exerciceDeletionFailed, (state, action) => {
21 | state.list.push(generateNotification(action.payload, NotificationType.ERROR));
22 | })
23 | .addCase(exerciceDeleted, (state) => {
24 | state.list.push(generateNotification("Exercice suppression réussie", NotificationType.SUCCESS));
25 | })
26 | .addCase(exerciceLoaded, (state) => {
27 | state.list.push(generateNotification("Exercice récupération réussie", NotificationType.SUCCESS));
28 | })
29 | .addCase(exerciceLoadingFailed, (state, action) => {
30 | state.list.push(generateNotification(action.payload, NotificationType.ERROR));
31 | })
32 | .addCase(exercicesLoadingFailed, (state, action) => {
33 | state.list.push(generateNotification(action.payload, NotificationType.ERROR));
34 | })
35 | .addCase(exerciceUpdateFailed, (state, action) => {
36 | state.list.push(generateNotification(action.payload, NotificationType.ERROR));
37 | })
38 | .addCase(exerciceUpdated, (state) => {
39 | state.list.push(generateNotification("Exercice mise à jour réussie", NotificationType.SUCCESS));
40 | });
41 | });
42 |
43 | export default addNotificationReducer;
--------------------------------------------------------------------------------
/src/notification/features/add-notification/notification-generator.service.ts:
--------------------------------------------------------------------------------
1 | import {NotificationType} from "@/src/notification/features/shared/notification.state.model";
2 |
3 | export const generateNotification = (message: string, type: NotificationType,) => {
4 | return {
5 | id: crypto.randomUUID(),
6 | message,
7 | type
8 | };
9 | };
10 |
--------------------------------------------------------------------------------
/src/notification/features/remove-notification/remove-notification.events.ts:
--------------------------------------------------------------------------------
1 | import {createAction} from "@reduxjs/toolkit";
2 |
3 | export const notificationRemoveStarted = createAction(
4 | "NOTIFICATION_REMOVE_STARTED",
5 | );
6 |
--------------------------------------------------------------------------------
/src/notification/features/remove-notification/remove-notification.reducer.ts:
--------------------------------------------------------------------------------
1 | import {notificationRemoveStarted} from "@/src/notification/features/remove-notification/remove-notification.events";
2 | import {createReducer} from "@reduxjs/toolkit";
3 | import {notificationsInitialState} from "@/src/notification/features/shared/notification.state.model";
4 |
5 | export const removeNotificationReducer = createReducer(notificationsInitialState, (builder) => {
6 | builder.addCase(notificationRemoveStarted, (state, action) => {
7 | state.list = state.list.filter((notification) => notification.id !== action.payload,);
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/notification/features/remove-notification/remove-notification.state-machine.md:
--------------------------------------------------------------------------------
1 | ```mermaid
2 | ---
3 | title: Remove Notification State
4 | ---
5 |
6 | flowchart TD
7 | A[
8 | Initial
9 |
10 | - data: n
11 | ]
12 |
13 | B[
14 | Notification Removed
15 |
16 | - data: n - deleted notification
17 | ]
18 |
19 | A -->|Remove notification started|B
20 |
21 | ```
--------------------------------------------------------------------------------
/src/notification/features/remove-notification/remove-notification.use-case.spec.ts:
--------------------------------------------------------------------------------
1 | import {AppStore} from "@/src/shared/application/root.store";
2 | import {notificationRemoveStarted} from "@/src/notification/features/remove-notification/remove-notification.events";
3 | import {createTestStore} from "@/src/shared/application/test/test.store";
4 | import {Notification, NotificationType} from "@/src/notification/features/shared/notification.state.model";
5 |
6 | describe("As a user I want to remove a notification", () => {
7 | let testStore: AppStore;
8 | let notificationIdToDelete: string;
9 | let notifications: Notification[];
10 |
11 | describe("Given there are notifications in the store", () => {
12 | beforeAll(() => {
13 | notifications = [{
14 | id: "1",
15 | message: "Notification 1",
16 | type: NotificationType.SUCCESS
17 | }, {
18 | id: "2",
19 | message: "Notification 2",
20 | type: NotificationType.ERROR
21 | },];
22 |
23 | testStore = createTestStore({
24 | notifications: {
25 | list: notifications,
26 | },
27 | });
28 |
29 | notificationIdToDelete = notifications[0].id;
30 | });
31 |
32 | describe("When I remove a notification by its ID", () => {
33 | beforeEach(() => {
34 | testStore.dispatch(notificationRemoveStarted(notificationIdToDelete));
35 | });
36 |
37 | test("Then the notification should be removed from the list", () => {
38 | const notification = testStore
39 | .getState()
40 | .notifications.list.find((notification) => notification.id === notificationIdToDelete,);
41 |
42 | expect(notification).toBeUndefined();
43 | });
44 |
45 | test("Then other notifications should remain in the list", () => {
46 | const remainingNotification = testStore
47 | .getState()
48 | .notifications.list.find((notification) => notification.id === notifications[1].id,);
49 |
50 | expect(remainingNotification).not.toBeUndefined();
51 | expect(remainingNotification?.message).toBe("Notification 2");
52 | });
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/notification/features/remove-notification/remove-notification.use-case.ts:
--------------------------------------------------------------------------------
1 | import { Thunk } from "@/src/shared/application/thunk.type";
2 | import { notificationRemoveStarted } from "@/src/notification/features/remove-notification/remove-notification.events";
3 |
4 | export const removeNotificationUseCase =
5 | (notificationId: string): Thunk =>
6 | async (dispatch) => {
7 | dispatch(notificationRemoveStarted(notificationId));
8 | };
9 |
--------------------------------------------------------------------------------
/src/notification/features/shared/notification.reducer.ts:
--------------------------------------------------------------------------------
1 | import {removeNotificationReducer} from "@/src/notification/features/remove-notification/remove-notification.reducer";
2 | import addNotificationReducer from "@/src/notification/features/add-notification/add-notification.reducer";
3 | import composeReducers from "@/src/shared/application/compose-reducers.service";
4 | import {NotificationsState} from "@/src/notification/features/shared/notification.state.model";
5 |
6 | const notificationsReducer = composeReducers(addNotificationReducer, removeNotificationReducer);
7 |
8 | export default notificationsReducer;
9 |
--------------------------------------------------------------------------------
/src/notification/features/shared/notification.state.model.ts:
--------------------------------------------------------------------------------
1 | import {RootState} from "@/src/shared/application/root.state";
2 |
3 | export type NotificationsState = {
4 | list: Notification[];
5 | }
6 |
7 | export type Notification = {
8 | id: string;
9 | message: string;
10 | type: NotificationType;
11 | };
12 |
13 | export enum NotificationType {
14 | SUCCESS = "success", ERROR = "error",
15 | }
16 |
17 | export const notificationsInitialState: NotificationsState = {
18 | list: [],
19 | };
20 |
21 | export const getNotificationsList = (state: RootState) => state.notifications.list;
--------------------------------------------------------------------------------
/src/notification/ui/component/Notifications.tsx:
--------------------------------------------------------------------------------
1 | import {NotificationsViewModel} from "@/src/notification/ui/component/Notifications.view-model";
2 | import {StyleSheet, Text, View} from "react-native";
3 |
4 | const Notifications = () => {
5 | const {notifications, handleCloseNotification} = NotificationsViewModel();
6 |
7 | return (
8 |
9 | {notifications.map((notification, index) => (
10 |
17 | {notification.message}
18 | handleCloseNotification(notification.id)}
21 | >
22 | ×
23 |
24 |
25 | ))}
26 |
27 | );
28 | };
29 |
30 | export default Notifications;
31 |
32 | const styles = StyleSheet.create({
33 | container: {
34 | position: "absolute",
35 | top: 0,
36 | left: 0,
37 | right: 0,
38 | zIndex: 1000,
39 | },
40 | notificationContainer: {
41 | width: "100%",
42 | paddingVertical: 15,
43 | paddingHorizontal: 20,
44 | borderBottomWidth: 1,
45 | borderBottomColor: "#ffffff55",
46 | flexDirection: "row",
47 | alignItems: "center",
48 | justifyContent: "space-between",
49 | },
50 | text: {
51 | color: "#fff",
52 | fontSize: 16,
53 | textAlign: "center",
54 | flex: 1,
55 | },
56 | closeButton: {
57 | color: "#fff",
58 | fontSize: 20,
59 | fontWeight: "bold",
60 | marginLeft: 10,
61 | },
62 | success: {
63 | backgroundColor: "#28a745",
64 | },
65 | error: {
66 | backgroundColor: "#dc3545",
67 | },
68 | });
69 |
--------------------------------------------------------------------------------
/src/notification/ui/component/Notifications.view-model.ts:
--------------------------------------------------------------------------------
1 | import {useDispatch, useSelector} from "react-redux";
2 |
3 | import {getNotificationsList} from "@/src/notification/features/shared/notification.state.model";
4 | import {removeNotificationUseCase} from "@/src/notification/features/remove-notification/remove-notification.use-case";
5 |
6 | export const NotificationsViewModel = () => {
7 | const dispatch = useDispatch();
8 |
9 | const notifications = useSelector(getNotificationsList);
10 |
11 | const handleCloseNotification = (id: string) => {
12 | dispatch(removeNotificationUseCase(id));
13 | };
14 |
15 | return {
16 | notifications,
17 | handleCloseNotification
18 | };
19 | };
20 |
21 |
--------------------------------------------------------------------------------
/src/shared/application/compose-reducers.service.ts:
--------------------------------------------------------------------------------
1 | import {Reducer, UnknownAction} from "redux";
2 |
3 | const composeReducers = (...reducers: Reducer[]) => {
4 | return (state: State, action: UnknownAction) => {
5 | return reducers.reduce((currentState, reducer) => {
6 | return reducer(currentState, action);
7 | }, state);
8 | };
9 | };
10 |
11 | export default composeReducers;
--------------------------------------------------------------------------------
/src/shared/application/root.reducer.ts:
--------------------------------------------------------------------------------
1 | import {combineReducers} from "@reduxjs/toolkit";
2 | import exercicesReducer from "@/src/exercice/features/shared/exercice.reducer";
3 | import notificationsReducer from "@/src/notification/features/shared/notification.reducer";
4 | import {RootState} from "@/src/shared/application/root.state";
5 | import {Reducer} from "redux";
6 |
7 | const rootReducer: Reducer = combineReducers({
8 | exercices: exercicesReducer,
9 | notifications: notificationsReducer,
10 | });
11 |
12 | export default rootReducer;
13 |
--------------------------------------------------------------------------------
/src/shared/application/root.state.ts:
--------------------------------------------------------------------------------
1 | import {NotificationsState} from "@/src/notification/features/shared/notification.state.model";
2 | import {ExercicesState} from "@/src/exercice/features/shared/exercice.state.model";
3 |
4 | export type RootState = {
5 | exercices: ExercicesState;
6 | notifications: NotificationsState
7 | }
--------------------------------------------------------------------------------
/src/shared/application/root.store.ts:
--------------------------------------------------------------------------------
1 | import {configureStore} from "@reduxjs/toolkit";
2 | import rootReducer from "@/src/shared/application/root.reducer";
3 | import ExerciceRepositoryInMemory from "@/src/exercice/features/shared/infrastructure/exercice.repository.in-memory";
4 | import {ExerciceRepositoryInterface} from "@/src/exercice/features/shared/exercice.repository.interface";
5 |
6 | const store = configureStore({
7 | reducer: rootReducer,
8 | middleware: (getDefaultMiddleware) => getDefaultMiddleware({
9 | thunk: {
10 | extraArgument: {
11 | exerciceRepository: new ExerciceRepositoryInMemory(),
12 | },
13 | },
14 | }),
15 | });
16 |
17 | export type AppStore = typeof store;
18 |
19 | export type Container = {
20 | exerciceRepository: ExerciceRepositoryInterface;
21 | };
22 |
23 | export default store;
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/shared/application/test/test.store.ts:
--------------------------------------------------------------------------------
1 | import {configureStore} from "@reduxjs/toolkit";
2 | import rootReducer from "@/src/shared/application/root.reducer";
3 |
4 | import {RootState} from "@/src/shared/application/root.state";
5 |
6 | export const createTestStore = (preloadedState?: Partial, extraArgument = {},) => configureStore({
7 | reducer: rootReducer,
8 | preloadedState,
9 | middleware: (getDefaultMiddleware) => getDefaultMiddleware({
10 | thunk: {
11 | extraArgument,
12 | },
13 | }),
14 | });
15 |
--------------------------------------------------------------------------------
/src/shared/application/thunk.type.ts:
--------------------------------------------------------------------------------
1 | import {Container} from "@/src/shared/application/root.store";
2 | import {Action} from "@reduxjs/toolkit";
3 | import {ThunkAction} from "redux-thunk";
4 | import {RootState} from "@/src/shared/application/root.state";
5 |
6 | export type Thunk> = ThunkAction;
7 |
--------------------------------------------------------------------------------
/src/shared/ui/component/home/Home.tsx:
--------------------------------------------------------------------------------
1 | import { View, Text, Image, StyleSheet } from "react-native";
2 |
3 | export default function Home() {
4 | return (
5 |
6 | Train Better
7 |
8 |
9 | );
10 | }
11 |
12 | const styles = StyleSheet.create({
13 | container: {
14 | flex: 1,
15 | backgroundColor: "#25292e",
16 | justifyContent: "center",
17 | alignItems: "center",
18 | },
19 | text: {
20 | color: "#fff",
21 | },
22 | logo: {
23 | height: 178,
24 | width: 290,
25 | },
26 | });
27 |
--------------------------------------------------------------------------------
/src/shared/ui/component/layout/ScreenLayout.tsx:
--------------------------------------------------------------------------------
1 | import Notifications from "@/src/notification/ui/component/Notifications";
2 | import { View, StyleSheet } from "react-native";
3 |
4 | const ScreenLayout = ({ children }: { children?: React.ReactNode }) => {
5 | return (
6 |
7 |
8 | {children}
9 |
10 | );
11 | };
12 |
13 | export default ScreenLayout;
14 |
15 | const styles = StyleSheet.create({
16 | container: {
17 | flex: 1,
18 | backgroundColor: "#25292e",
19 | },
20 | content: {
21 | flex: 1,
22 | width: "100%",
23 | paddingHorizontal: 16,
24 | },
25 | });
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": ["./*"]
7 | }
8 | },
9 | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
10 | }
11 |
--------------------------------------------------------------------------------