├── .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 | | Logo JS | Logo JS | Logo JS | Logo JS | Logo JS | Logo JS | 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 | ![clean-archi.png](doc/clean-archi.png) 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 | ![redux-message-bus.png](doc/redux-message-bus.png) 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 | ![react-redux-clean-archi.png](doc/react-redux-clean-eda.png) 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 |