├── .editorconfig ├── .eslintrc.cjs ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.cjs ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public └── mockServiceWorker.js ├── src ├── App.spec.tsx ├── App.tsx ├── client │ └── client.ts ├── components │ └── todo │ │ └── todo.component.tsx ├── constants │ └── routes.ts ├── globals.d.ts ├── index.css ├── main.tsx ├── mocks │ ├── browser.ts │ ├── handlers.ts │ ├── handlers │ │ ├── auth.handler.ts │ │ ├── dummy-todo-server.ts │ │ └── todo.handler.ts │ └── server_dummies │ │ ├── auth │ │ └── get_user_info.response.ts │ │ └── todo │ │ ├── get_todo.reponse.ts │ │ └── list_todo.response.ts ├── pages │ ├── auth │ │ ├── auth.controller.tsx │ │ ├── auth.page.tsx │ │ ├── auth.route.tsx │ │ └── auth.test.tsx │ └── dashboard │ │ ├── components │ │ ├── index │ │ │ ├── index.controller.tsx │ │ │ ├── index.page.tsx │ │ │ ├── index.route.tsx │ │ │ └── index.test.tsx │ │ ├── sidebar │ │ │ └── sidebar.component.tsx │ │ ├── todo-details │ │ │ ├── components │ │ │ │ ├── create-todo.controller.tsx │ │ │ │ └── create-todo.modal.tsx │ │ │ ├── todo-details.controller.tsx │ │ │ ├── todo-details.page.tsx │ │ │ ├── todo-details.route.tsx │ │ │ └── todo-details.test.tsx │ │ └── todo-list │ │ │ ├── components │ │ │ ├── create-todo.controller.tsx │ │ │ └── create-todo.modal.tsx │ │ │ ├── todo-list.controller.tsx │ │ │ ├── todo-list.page.tsx │ │ │ ├── todo-list.route.tsx │ │ │ └── todo-list.test.tsx │ │ ├── dashboard.controller.tsx │ │ ├── dashboard.page.tsx │ │ └── dashboard.route.tsx ├── queries │ ├── auth.queries.ts │ ├── queryClient.ts │ └── todo.queries.ts ├── repositories │ ├── auth.repository.ts │ └── todo.repository.ts ├── router.tsx ├── services │ ├── auth.service.spec.ts │ └── auth.service.ts ├── types │ ├── ISession.d.ts │ ├── ITodo.d.ts │ └── server │ │ ├── ILoginResponse.d.ts │ │ └── todos.d.ts ├── vite-env.d.ts └── vitest.setup.tsx ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | indent_style = space 17 | indent_size = 2 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | 'prettier', 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | rules: { 13 | '@typescript-eslint/no-non-null-assertion': 0, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /dist 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .eslintcache 23 | .env 24 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.3.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /dist 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .eslintcache 23 | .env 24 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | singleQuote: true, 4 | htmlWhitespaceSensitivity: 'strict', 5 | printWidth: 100, 6 | useTabs: true, 7 | }; 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bierner.markdown-mermaid", 4 | "cpylua.language-postcss", 5 | "dbaeumer.vscode-eslint", 6 | "eamodio.gitlens", 7 | "EditorConfig.EditorConfig", 8 | "esbenp.prettier-vscode", 9 | "formulahendry.auto-rename-tag", 10 | "GitHub.copilot", 11 | "Gruntfuggly.todo-tree", 12 | "johnpapa.vscode-peacock", 13 | "marp-team.marp-vscode", 14 | "mattpocock.ts-error-translator", 15 | "ms-playwright.playwright", 16 | "ms-vsliveshare.vsliveshare-pack", 17 | "ms-vsliveshare.vsliveshare", 18 | "redhat.vscode-yaml", 19 | "streetsidesoftware.code-spell-checker", 20 | "stylelint.vscode-stylelint", 21 | "unifiedjs.vscode-mdx", 22 | "ZixuanChen.vitest-explorer" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "node", 7 | "request": "launch", 8 | "name": "Debug Current Test File", 9 | "autoAttachChildProcesses": true, 10 | "skipFiles": ["/**", "**/node_modules/**"], 11 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 12 | "args": ["run", "${relativeFile}"], 13 | "smartStep": true, 14 | "console": "integratedTerminal" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": true, 4 | "source.fixAll": true 5 | }, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "cSpell.words": ["chakra", "tanstack", "todos"], 8 | "editor.formatOnSave": true 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frontend Architecture 2 | 3 | Este repositorio contiene un ejemplo de una arquitectura frontend para una SPA. 4 | 5 | Explicaciones más detalladas estrán disponibles en el libro `Frontend Architecture` de [@iagolast](https://twitter.com/iagolast) 6 | 7 | -- 8 | 9 | ⚠️ Work in progress ⚠️ 10 | 11 | - Ni el libro ni el repositorio están terminados de momento. 12 | 13 | -- 14 | 15 | ## Getting started 16 | 17 | ### Pre-requisites 18 | 19 | - Node 20 20 | 21 | ### Installation 22 | 23 | ```bash 24 | npm install 25 | ``` 26 | 27 | ### Build for production 28 | 29 | ```bash 30 | npm run build 31 | ``` 32 | 33 | ### Run for dev 34 | 35 | ```bash 36 | npm run dev 37 | ``` 38 | 39 | ## Repo details 40 | 41 | ### Dev tools 42 | 43 | - node: Permite ejecutar javascript typescript sin un navegador. 44 | - nvm: Permite instalar y usar diferentes versiones de node simultaneamente. 45 | - build: 46 | - vite: Es una herramienta que permite construir y compilar el proyecto. 47 | - code style: 48 | - Prettier: Es una herramienta que permite formatear el código. 49 | - Eslint: Es una herramienta que permite detectar errores o problemas potenciales en el código. 50 | - testing: 51 | - vitest: Es una herramienta que permite ejecutar tests 52 | - testing-library: Permite ejecutar test interactuando con la app de forma similar a como lo haría un usuario. 53 | - user-event: Permite simular a un usuario interactuando con la aplicación. 54 | - msw: Permite simular llamadas a servidores. 55 | 56 | ### Libraries 57 | 58 | TBD 59 | 60 | ### Folder structure 61 | 62 | - **.vscode:** : Contiene configuraciones y opciones para VSCode. 63 | - De esa forma todas las personas que trabajan en el proyecto, tienen la misma configuración. 64 | - **extensions.json:** Contiene las extensiones que se recomiendan para el proyecto. 65 | - **launch.json:** Contiene la configuración para debuggear el proyecto. 66 | - **settings.json:** Contiene las configuraciones específicas del editor. 67 | - Por ejemplo, que al guardar se auto-organicen los imports y se formatee el código. 68 | - **node_modules:** Contiene las dependencias npm del proyecto. 69 | - **public:** Contiene los archivos estáticos del proyecto. 70 | - **src:** Contiene código del proyecto. 71 | - **.editorconfig:** Es un archivo que sirve para que diferentes editores de código se comporten de la misma forma. 72 | - Por ejemplo, decidir usar tabuladores en lugar de espacios o añadir una linea al final de cada archivo. 73 | - **.eslintrc.cjs:** Es un archivo de configuración de eslint. 74 | - EsLint es una herramienta que sirve para detectar errores o problemas potenciales en el código. 75 | - Ejemplos: variables sin usar, o asignar en lugar de comparar (=) vs === 76 | - **.gitignore:** Es un archivo que le dice a git que archivos o carpetas no debe subir al repositorio. 77 | - **.nvmrc** Es un archivo que le dice a nvm que versión de node debe usar. 78 | - Es importante que todos los desarrolladores usen la misma versión de node para evitar problemas. 79 | - **.prettierrc:** Es un archivo de configuración de prettier. 80 | - Prettier es una herramienta que sirve para formatear el código. 81 | - A diferencia de EsLint, Prettier no detecta errores, solo formatea el código. 82 | - **index.html:** Es la única página HTML del proyecto. 83 | - **package-lock.json:** Contiene informacion exacta de las dependencias del proyecto. 84 | - **package.json:** Es un archivo que contiene información del proyecto y sus dependencias. 85 | - **README.md:** Es un archivo que contiene información útil sobre el proyecto. 86 | - **tsconfig.json:** Es el archivo de configuración de typescript. 87 | - **tsconfig.node.json:** Es el archivo de configuración de typescript específico para los comandos de node. 88 | - **vite.config.ts:** Es el archivo de configuración de Vite. La herramienta que se usa para compilar el proyecto. 89 | - **vitest.config.ts:** Es el archivo de configuración de Vitest. La herramienta que se usa para probar el proyecto. 90 | 91 | #### SRC 92 | 93 | En esta carpeta encontramos el código del proyecto. 94 | 95 | El punto de entrada es `main.tsx` que a su vez monta una aplicación React `App.tsx` en el elemento `root` del `index.html`. Al tener App aislado podemos realizar tests unitarios que imitan casi perfectamente el comportamiento de usuarios interactuando con la aplicación. 96 | 97 | - **main.tsx:** Punto de entrada de la aplicación. 98 | - **App.tsx:** Componente padre de la aplicación. 99 | - **router.tsx:** Contiene las rutas de la aplicación (usando react-router). 100 | - **assets**: En esta carpeta se encuentran recursos estáticos que pueden ser procesados por vitest. 101 | - **types:**: En esta carpeta encontramos los objetos de dominio de la aplicación representados como tipos de typescript. 102 | - **services:**: En esta carpeta encontramos los `servicios` de la aplicación. 103 | - Los servicios representan los casos de uso de la aplicación. 104 | - Se implementan mediante funciones puras y únicamente dependen de los objetos de dominio. 105 | - **client:** En esta carpeta se encuentra el codigo del cliente que se encarga de comunicarse con el servidor. 106 | - Tenemos un único cliente que se encarga de comunicarse con el servidor. 107 | - Las credenciales se extraen automáticamente de localstorage y se añaden a cada petición. 108 | - Se utiliza Axios 109 | - **repositories:** En esta carpeta encontramos los `repositorios` de los recursos de la aplicación. 110 | - Un repositorio es una abstracción que nos permite interactuar con un recurso. 111 | - Por ejemplo, un repositorio de usuarios nos permite obtener, crear, actualizar y borrar usuarios. 112 | - Similar al [patrón repositorio](https://martinfowler.com/eaaCatalog/repository.html) 113 | - **queries:** En esta carpeta encontramos las `queries` y las mutaciones de los recursos de la aplicación. 114 | - Podemos pensar en una adaptación del [patrón CQRS](https://martinfowler.com/bliki/CQRS.html) 115 | - Una [query](https://tanstack.com/query/latest/docs/react/guides/queries) es una dependencia declarativa de un recurso asíncrono. 116 | - Una [mutación](https://tanstack.com/query/latest/docs/react/guides/mutations) es una función que contiene "efectos secundarios". 117 | - **components:** En esta carpeta encontramos los componentes que reutilizamos de forma global en la aplicación. 118 | - **pages:** En esta carpeta encontramos componentes asociados a rutas (página) 119 | - Por ejemplo `auth.page.tsx` es el componente asociado a la ruta `/auth`. 120 | - Cada página tiene su propio directorio donde encontramos los siguientes archivos: 121 | - name.page.tsx: Componente de react que representa la página. 122 | - name.test.tsx: Tests unitarios del componente. 123 | - name.controller.tsx: Controlador de la página. 124 | - name.route.tsx: Información de la ruta. 125 | - `components`: En esta carpeta encontramos los componentes o páginas hijas que se usan en esta página. 126 | - si un componente se usa en más de una página, se debe mover a la carpeta `components` de la aplicación. 127 | - Este proceso se repite de foma recursiva. 128 | - Los imports hacia arriba están prohibidos. 129 | - De esta forma podemos controlar el alcance de los cambios --> Cambios en una carpeta no afectan a sus hermanos. 130 | - En el ejemplo cambios en /pages/auth/dashboard/ no afectan a /pages/auth/login/ 131 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend-architecture", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "prettier": "prettier --write .", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src", 10 | "preview": "vite preview", 11 | "start": "vite", 12 | "test": "vitest" 13 | }, 14 | "dependencies": { 15 | "@chakra-ui/react": "^2.7.1", 16 | "@emotion/react": "^11.11.1", 17 | "@emotion/styled": "^11.11.0", 18 | "@tanstack/react-query": "^4.29.13", 19 | "axios": "^1.4.0", 20 | "framer-motion": "^10.12.16", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "react-hook-form": "^7.44.3", 24 | "react-icons": "^4.9.0", 25 | "react-router-dom": "^6.12.1" 26 | }, 27 | "devDependencies": { 28 | "@apto-payments/test-server": "^0.0.12", 29 | "@apto-payments/test-server-matchers": "^0.0.1", 30 | "@remix-run/web-fetch": "^4.3.4", 31 | "@testing-library/jest-dom": "^5.16.5", 32 | "@testing-library/react": "^14.0.0", 33 | "@testing-library/user-event": "^14.4.3", 34 | "@types/jest": "^29.5.2", 35 | "@types/react": "^18.2.12", 36 | "@types/react-dom": "^18.2.5", 37 | "@typescript-eslint/eslint-plugin": "^5.60.0", 38 | "@vitejs/plugin-react": "^4.0.0", 39 | "@vitest/browser": "^0.32.0", 40 | "autoprefixer": "^10.4.14", 41 | "eslint-config-prettier": "^8.8.0", 42 | "eslint-plugin-react-hooks": "^4.6.0", 43 | "jsdom": "^22.1.0", 44 | "msw": "^1.2.2", 45 | "prettier": "^2.8.8", 46 | "typescript": "^5.1.3", 47 | "vite": "^4.3.9", 48 | "vitest": "^0.32.0" 49 | }, 50 | "msw": { 51 | "workerDirectory": "public" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker (0.49.3). 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'; 12 | const activeClientIds = new Set(); 13 | 14 | self.addEventListener('install', function () { 15 | self.skipWaiting(); 16 | }); 17 | 18 | self.addEventListener('activate', function (event) { 19 | event.waitUntil(self.clients.claim()); 20 | }); 21 | 22 | self.addEventListener('message', async function (event) { 23 | const clientId = event.source.id; 24 | 25 | if (!clientId || !self.clients) { 26 | return; 27 | } 28 | 29 | const client = await self.clients.get(clientId); 30 | 31 | if (!client) { 32 | return; 33 | } 34 | 35 | const allClients = await self.clients.matchAll({ 36 | type: 'window', 37 | }); 38 | 39 | switch (event.data) { 40 | case 'KEEPALIVE_REQUEST': { 41 | sendToClient(client, { 42 | type: 'KEEPALIVE_RESPONSE', 43 | }); 44 | break; 45 | } 46 | 47 | case 'INTEGRITY_CHECK_REQUEST': { 48 | sendToClient(client, { 49 | type: 'INTEGRITY_CHECK_RESPONSE', 50 | payload: INTEGRITY_CHECKSUM, 51 | }); 52 | break; 53 | } 54 | 55 | case 'MOCK_ACTIVATE': { 56 | activeClientIds.add(clientId); 57 | 58 | sendToClient(client, { 59 | type: 'MOCKING_ENABLED', 60 | payload: true, 61 | }); 62 | break; 63 | } 64 | 65 | case 'MOCK_DEACTIVATE': { 66 | activeClientIds.delete(clientId); 67 | break; 68 | } 69 | 70 | case 'CLIENT_CLOSED': { 71 | activeClientIds.delete(clientId); 72 | 73 | const remainingClients = allClients.filter((client) => { 74 | return client.id !== clientId; 75 | }); 76 | 77 | // Unregister itself when there are no more clients 78 | if (remainingClients.length === 0) { 79 | self.registration.unregister(); 80 | } 81 | 82 | break; 83 | } 84 | } 85 | }); 86 | 87 | self.addEventListener('fetch', function (event) { 88 | const { request } = event; 89 | const accept = request.headers.get('accept') || ''; 90 | 91 | // Bypass server-sent events. 92 | if (accept.includes('text/event-stream')) { 93 | return; 94 | } 95 | 96 | // Bypass navigation requests. 97 | if (request.mode === 'navigate') { 98 | return; 99 | } 100 | 101 | // Opening the DevTools triggers the "only-if-cached" request 102 | // that cannot be handled by the worker. Bypass such requests. 103 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 104 | return; 105 | } 106 | 107 | // Bypass all requests when there are no active clients. 108 | // Prevents the self-unregistered worked from handling requests 109 | // after it's been deleted (still remains active until the next reload). 110 | if (activeClientIds.size === 0) { 111 | return; 112 | } 113 | 114 | // Generate unique request ID. 115 | const requestId = Math.random().toString(16).slice(2); 116 | 117 | event.respondWith( 118 | handleRequest(event, requestId).catch((error) => { 119 | if (error.name === 'NetworkError') { 120 | console.warn( 121 | '[MSW] Successfully emulated a network error for the "%s %s" request.', 122 | request.method, 123 | request.url 124 | ); 125 | return; 126 | } 127 | 128 | // At this point, any exception indicates an issue with the original request/response. 129 | console.error( 130 | `\ 131 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, 132 | request.method, 133 | request.url, 134 | `${error.name}: ${error.message}` 135 | ); 136 | }) 137 | ); 138 | }); 139 | 140 | async function handleRequest(event, requestId) { 141 | const client = await resolveMainClient(event); 142 | const response = await getResponse(event, client, requestId); 143 | 144 | // Send back the response clone for the "response:*" life-cycle events. 145 | // Ensure MSW is active and ready to handle the message, otherwise 146 | // this message will pend indefinitely. 147 | if (client && activeClientIds.has(client.id)) { 148 | (async function () { 149 | const clonedResponse = response.clone(); 150 | sendToClient(client, { 151 | type: 'RESPONSE', 152 | payload: { 153 | requestId, 154 | type: clonedResponse.type, 155 | ok: clonedResponse.ok, 156 | status: clonedResponse.status, 157 | statusText: clonedResponse.statusText, 158 | body: clonedResponse.body === null ? null : await clonedResponse.text(), 159 | headers: Object.fromEntries(clonedResponse.headers.entries()), 160 | redirected: clonedResponse.redirected, 161 | }, 162 | }); 163 | })(); 164 | } 165 | 166 | return response; 167 | } 168 | 169 | // Resolve the main client for the given event. 170 | // Client that issues a request doesn't necessarily equal the client 171 | // that registered the worker. It's with the latter the worker should 172 | // communicate with during the response resolving phase. 173 | async function resolveMainClient(event) { 174 | const client = await self.clients.get(event.clientId); 175 | 176 | if (client?.frameType === 'top-level') { 177 | return client; 178 | } 179 | 180 | const allClients = await self.clients.matchAll({ 181 | type: 'window', 182 | }); 183 | 184 | return allClients 185 | .filter((client) => { 186 | // Get only those clients that are currently visible. 187 | return client.visibilityState === 'visible'; 188 | }) 189 | .find((client) => { 190 | // Find the client ID that's recorded in the 191 | // set of clients that have registered the worker. 192 | return activeClientIds.has(client.id); 193 | }); 194 | } 195 | 196 | async function getResponse(event, client, requestId) { 197 | const { request } = event; 198 | const clonedRequest = request.clone(); 199 | 200 | function passthrough() { 201 | // Clone the request because it might've been already used 202 | // (i.e. its body has been read and sent to the client). 203 | const headers = Object.fromEntries(clonedRequest.headers.entries()); 204 | 205 | // Remove MSW-specific request headers so the bypassed requests 206 | // comply with the server's CORS preflight check. 207 | // Operate with the headers as an object because request "Headers" 208 | // are immutable. 209 | delete headers['x-msw-bypass']; 210 | 211 | return fetch(clonedRequest, { headers }); 212 | } 213 | 214 | // Bypass mocking when the client is not active. 215 | if (!client) { 216 | return passthrough(); 217 | } 218 | 219 | // Bypass initial page load requests (i.e. static assets). 220 | // The absence of the immediate/parent client in the map of the active clients 221 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 222 | // and is not ready to handle requests. 223 | if (!activeClientIds.has(client.id)) { 224 | return passthrough(); 225 | } 226 | 227 | // Bypass requests with the explicit bypass header. 228 | // Such requests can be issued by "ctx.fetch()". 229 | if (request.headers.get('x-msw-bypass') === 'true') { 230 | return passthrough(); 231 | } 232 | 233 | // Notify the client that a request has been intercepted. 234 | const clientMessage = await sendToClient(client, { 235 | type: 'REQUEST', 236 | payload: { 237 | id: requestId, 238 | url: request.url, 239 | method: request.method, 240 | headers: Object.fromEntries(request.headers.entries()), 241 | cache: request.cache, 242 | mode: request.mode, 243 | credentials: request.credentials, 244 | destination: request.destination, 245 | integrity: request.integrity, 246 | redirect: request.redirect, 247 | referrer: request.referrer, 248 | referrerPolicy: request.referrerPolicy, 249 | body: await request.text(), 250 | bodyUsed: request.bodyUsed, 251 | keepalive: request.keepalive, 252 | }, 253 | }); 254 | 255 | switch (clientMessage.type) { 256 | case 'MOCK_RESPONSE': { 257 | return respondWithMock(clientMessage.data); 258 | } 259 | 260 | case 'MOCK_NOT_FOUND': { 261 | return passthrough(); 262 | } 263 | 264 | case 'NETWORK_ERROR': { 265 | const { name, message } = clientMessage.data; 266 | const networkError = new Error(message); 267 | networkError.name = name; 268 | 269 | // Rejecting a "respondWith" promise emulates a network error. 270 | throw networkError; 271 | } 272 | } 273 | 274 | return passthrough(); 275 | } 276 | 277 | function sendToClient(client, message) { 278 | return new Promise((resolve, reject) => { 279 | const channel = new MessageChannel(); 280 | 281 | channel.port1.onmessage = (event) => { 282 | if (event.data && event.data.error) { 283 | return reject(event.data.error); 284 | } 285 | 286 | resolve(event.data); 287 | }; 288 | 289 | client.postMessage(message, [channel.port2]); 290 | }); 291 | } 292 | 293 | function sleep(timeMs) { 294 | return new Promise((resolve) => { 295 | setTimeout(resolve, timeMs); 296 | }); 297 | } 298 | 299 | async function respondWithMock(response) { 300 | await sleep(response.delay); 301 | return new Response(response.body, response); 302 | } 303 | -------------------------------------------------------------------------------- /src/App.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react'; 2 | import { describe, expect, test } from 'vitest'; 3 | import App from './App'; 4 | import { dummy_login_response } from './mocks/server_dummies/auth/get_user_info.response'; 5 | import authService from './services/auth.service'; 6 | 7 | describe('', () => { 8 | test('should redirect to login when the user is not authenticated', () => { 9 | authService.logout(); 10 | window.history.pushState({}, '', '/'); 11 | 12 | render(); 13 | 14 | return waitFor(() => { 15 | expect(window.location.pathname).toEqual('/auth'); 16 | }); 17 | }); 18 | 19 | test('should redirect to dashboard when the user is no', () => { 20 | authService.login(dummy_login_response.token); 21 | window.history.pushState({}, '', '/'); 22 | 23 | render(); 24 | 25 | return waitFor(() => { 26 | expect(window.location.pathname).toEqual('/dashboard'); 27 | expect(screen.getByText('info@iagolast.dev')).toBeVisible(); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from '@chakra-ui/react'; 2 | import { QueryClientProvider } from '@tanstack/react-query'; 3 | import { RouterProvider, createBrowserRouter } from 'react-router-dom'; 4 | import { queryClient } from './queries/queryClient'; 5 | import { router } from './router'; 6 | 7 | export default function App() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/client/client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | /** 4 | * Singleton to make requests to the server 5 | */ 6 | const client = axios.create({ 7 | baseURL: '', 8 | headers: {}, 9 | }); 10 | 11 | /** 12 | * Add a request interceptor to add the token to the headers 13 | */ 14 | client.interceptors.request.use( 15 | (config) => { 16 | const token = localStorage.getItem('token'); 17 | // const token = `eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhZ1dFRko2cENVN0FJX2NLYlF5c3RUUjVxWE5pcGR6VTdLUEgwT0R2bDhrIn0.eyJleHAiOjE2ODczMzA5MTcsImlhdCI6MTY4NzMzMDYxNywiYXV0aF90aW1lIjoxNjg3MzMwNTY0LCJqdGkiOiJiYTVlYzRkNy1mMDgzLTQ3ZjctODQ5OC1jNDBiOTYxMTJmNTEiLCJpc3MiOiJodHRwczovL2Rldi5xZW50YS5jb20vYXV0aC9yZWFsbXMvcWVudGEiLCJhdWQiOlsicGF5YnlsaW5rIiwicG9ydGFsIiwiYWNjb3VudCJdLCJzdWIiOiI5YzFlMmU1ZS02YjBmLTQwYTUtODQ0MC1hNGFmMDI3Yjc1NzQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJpc3N1aW5nLW9wZXJhdGlvbnMtdWkiLCJzZXNzaW9uX3N0YXRlIjoiNmM0Y2E1ZDktNGJiNi00NjI5LThlNjAtMzYxNTdjNWVhMjgzIiwiYWNyIjoiMCIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1xZW50YSJdfSwicmVzb3VyY2VfYWNjZXNzIjp7InBheWJ5bGluayI6eyJyb2xlcyI6WyJwYXlieWxpbmstYWNjZXNzIl19LCJpc3N1aW5nLW9wZXJhdGlvbnMtdWkiOnsicm9sZXMiOlsiaXNzdWluZy1hZG1pbiJdfSwicG9ydGFsIjp7InJvbGVzIjpbImlzc3VpbmctYWRtaW4iXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIGVtYWlsIHByb2ZpbGUiLCJzaWQiOiI2YzRjYTVkOS00YmI2LTQ2MjktOGU2MC0zNjE1N2M1ZWEyODMiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IklhZ28gTGFzdHJhIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiaWFnb2xhc3QiLCJnaXZlbl9uYW1lIjoiSWFnbyIsImZhbWlseV9uYW1lIjoiTGFzdHJhIiwiZW1haWwiOiJpYWdvLmxhc3RyYUBxZW50YS5jb20ifQ.CIoCZhHylthpxmKanm5DgaTckm7CKAzp2T9DOJW9LST5lijn-yBhoV6gYwulJ28n8pB2NKOR1JNF4W6k1XjGjmA9zAG7CQ9k5rLggFI_Yfo9r1kpSO1mZkV0s2nABlGzho96IvBI3FJSju4ybsL78T0hFYunowZGXQK8KJVV8AZmLyv0N-UIix4BtUebcPs3mvwa-33KSL0bTI0lDcDU3ma9rDawXRxWaz-Rt6IjChoJQbdxfKTNSfQfXKpIq-bTc-IjO7LYhnG9BO8f_sIaAH12ET-LfaIJrdxnIeuezJLA2Jlw7mzF66VGx0xozeJHaeiB5j98GYtf8GqwKaidXg`; 18 | 19 | if (token) { 20 | config.headers.Authorization = `Bearer ${token}`; 21 | } 22 | 23 | return config; 24 | }, 25 | (error) => { 26 | return Promise.reject(error); 27 | } 28 | ); 29 | 30 | export default client; 31 | -------------------------------------------------------------------------------- /src/components/todo/todo.component.tsx: -------------------------------------------------------------------------------- 1 | import { TODO_DETAILS_ROUTE } from '@/constants/routes'; 2 | import ITodo from '@/types/ITodo'; 3 | import { Card, CardBody, CardHeader, Heading, Text } from '@chakra-ui/react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | interface ITodoProps { 7 | todo: ITodo; 8 | } 9 | 10 | export default function Todo(props: ITodoProps) { 11 | const navigate = useNavigate(); 12 | 13 | return ( 14 | { 21 | navigate({ pathname: TODO_DETAILS_ROUTE.replace(':id', props.todo.id) }); 22 | }} 23 | textDecoration={props.todo.completed ? 'line-through' : 'none'} 24 | > 25 | 26 | 27 | {props.todo.title} 28 | 29 | 30 | 31 | 32 | {props.todo.description} 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const BASE_ROUTE = '/'; 2 | export const LOGIN_ROUTE = '/login'; 3 | export const DASHBOARD_ROUTE = '/dashboard'; 4 | export const TODO_LIST_ROUTE = '/dashboard/todo'; 5 | export const TODO_DETAILS_ROUTE = '/dashboard/todo/:id'; 6 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'; 2 | 3 | /** 4 | * Extend vitest Assertion interface with @testing-library/jest-dom matchers 5 | */ 6 | declare module 'vitest' { 7 | interface Assertion extends jest.Matchers, TestingLibraryMatchers {} 8 | } 9 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IagoLast/frontend-architecture/9061763252c7e27c6cc6bc2991c80673a78602d7/src/index.css -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | 5 | if (process.env.NODE_ENV === 'development') { 6 | const { worker } = await import('./mocks/browser'); 7 | worker.start(); 8 | } 9 | 10 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | // src/mocks/browser.js 2 | import { setupWorker } from 'msw'; 3 | import { handlers } from './handlers'; 4 | 5 | // This configures a Service Worker with the given request handlers. 6 | export const worker = setupWorker(...handlers); 7 | -------------------------------------------------------------------------------- /src/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { authHandler } from './handlers/auth.handler'; 2 | import { todoHandler } from './handlers/todo.handler'; 3 | 4 | export const handlers = [...authHandler, ...todoHandler]; 5 | -------------------------------------------------------------------------------- /src/mocks/handlers/auth.handler.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { dummy_login_response } from '../server_dummies/auth/get_user_info.response'; 3 | 4 | export const authHandler = [ 5 | rest.post('/login', (_, res, ctx) => { 6 | return res(ctx.json(dummy_login_response), ctx.delay(200)); 7 | }), 8 | ]; 9 | -------------------------------------------------------------------------------- /src/mocks/handlers/dummy-todo-server.ts: -------------------------------------------------------------------------------- 1 | import ITodo from '@/types/ITodo'; 2 | 3 | const todos = JSON.parse(localStorage.getItem('todos') || '[]') as Array; 4 | 5 | async function listTodos(): Promise> { 6 | return todos; 7 | } 8 | 9 | async function getTodo(id: string): Promise { 10 | return todos.find((todo) => todo.id === id); 11 | } 12 | 13 | async function updateTodo(todo: ITodo): Promise { 14 | const index = todos.findIndex((t) => t.id === todo.id); 15 | if (index === -1) { 16 | todos.push(todo); 17 | } 18 | todos[index] = todo; 19 | } 20 | 21 | export default { 22 | listTodos, 23 | getTodo, 24 | updateTodo, 25 | }; 26 | -------------------------------------------------------------------------------- /src/mocks/handlers/todo.handler.ts: -------------------------------------------------------------------------------- 1 | import ITodo from '@/types/ITodo'; 2 | import { rest } from 'msw'; 3 | import dummyTodoServer from './dummy-todo-server'; 4 | 5 | export const todoHandler = [ 6 | rest.get('/todos', async (_, res, ctx) => { 7 | const todos = await dummyTodoServer.listTodos(); 8 | return res(ctx.json({ todos })); 9 | }), 10 | 11 | rest.get('/todos/:id', async (req, res, ctx) => { 12 | const todo = await dummyTodoServer.getTodo(req.params['id'] as string); 13 | if (!todo) { 14 | return res(ctx.status(404)); 15 | } 16 | return res(ctx.json({ todo })); 17 | }), 18 | 19 | rest.put('/todos/:id', async (req, res, ctx) => { 20 | const body = await (req.json() as Promise); 21 | 22 | dummyTodoServer.updateTodo({ 23 | id: body.id, 24 | completed: body.completed, 25 | description: body.description, 26 | title: body.title, 27 | }); 28 | 29 | return res(ctx.json({}), ctx.delay(200)); 30 | }), 31 | ]; 32 | -------------------------------------------------------------------------------- /src/mocks/server_dummies/auth/get_user_info.response.ts: -------------------------------------------------------------------------------- 1 | import ILoginResponse from '@/types/server/ILoginResponse'; 2 | 3 | export const dummy_login_response: ILoginResponse = { 4 | token: 5 | 'eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImFnV0VGSjZwQ1U3QUlfY0tiUXlzdFRSNXFYTmlwZHpVN0tQSDBPRHZsOGsifQ.eyJzdWIiOiI5YzFlMmU1ZS02YjBmLTQwYTUtODQ0MC1hNGFmMDI3Yjc1NzQiLCJzaWQiOiIwOTUyY2MwNS1lM2YxLTQ5MGMtYjA5ZS02OGJmYWUxNWFiMjAiLCJuYW1lIjoiSWFnbyBMYXN0cmEiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJpYWdvbGFzdCIsImVtYWlsIjoiaW5mb0BpYWdvbGFzdC5kZXYifQ.rxk5zkEQPW6S4q5bneQxqz00Dkqw1UZY1-aHntN3Le2UaiGyqm1JNvUYIy-ycMxyoQfkroc8298bKKQ2O_9Bsw', 6 | }; 7 | -------------------------------------------------------------------------------- /src/mocks/server_dummies/todo/get_todo.reponse.ts: -------------------------------------------------------------------------------- 1 | import IGetTodoResponse from '@/types/server/todos'; 2 | 3 | export const get_todo_response: IGetTodoResponse = { 4 | todo: { 5 | id: '1', 6 | title: 'Undestand folder structure', 7 | description: 'Understand the folder structure of the project', 8 | completed: false, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/mocks/server_dummies/todo/list_todo.response.ts: -------------------------------------------------------------------------------- 1 | import { IListTodoResponse } from '@/types/server/todos'; 2 | 3 | export const list_todo_response: IListTodoResponse = { 4 | todos: [ 5 | { 6 | id: '1', 7 | title: 'Undestand folder structure', 8 | description: 'Understand the folder structure of the project', 9 | completed: false, 10 | }, 11 | { 12 | id: '2', 13 | title: 'Read documentation', 14 | description: 'Read the documentation of the project', 15 | completed: false, 16 | }, 17 | { 18 | id: '3', 19 | title: 'Buy the frontend architecture book', 20 | description: 'Buy the book and learn how to build SPA apps using React', 21 | completed: false, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/pages/auth/auth.controller.tsx: -------------------------------------------------------------------------------- 1 | import { useLoginMutation } from '@/queries/auth.queries'; 2 | import authService from '@/services/auth.service'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | export function useAuthPage() { 7 | const navigate = useNavigate(); 8 | const loginMutation = useLoginMutation(); 9 | 10 | const form = useForm({ 11 | defaultValues: { 12 | username: '', 13 | password: '', 14 | }, 15 | }); 16 | 17 | const handleSubmit = form.handleSubmit((data) => { 18 | console.warn(data); 19 | return loginMutation.mutateAsync(data).then((res) => { 20 | authService.login(res.token); 21 | navigate('/dashboard'); 22 | }); 23 | }); 24 | 25 | return { 26 | form, 27 | handleSubmit, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/auth/auth.page.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Container, FormControl, FormLabel, Input, Stack } from '@chakra-ui/react'; 2 | import { useAuthPage } from './auth.controller'; 3 | 4 | export default function AuthPage() { 5 | const { form, handleSubmit } = useAuthPage(); 6 | 7 | return ( 8 | 9 | 10 | 17 | 18 | 19 | 20 | Username 21 | 22 | 23 | 24 | Password 25 | 26 | 27 | 28 | 29 | 30 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/auth/auth.route.tsx: -------------------------------------------------------------------------------- 1 | import authService from '@/services/auth.service'; 2 | import { RouteObject, redirect } from 'react-router-dom'; 3 | import AuthPage from './auth.page'; 4 | 5 | export const authRoute: RouteObject = { 6 | path: '/auth', 7 | Component: AuthPage, 8 | loader() { 9 | if (authService.isAuthenticated()) { 10 | return redirect('/dashboard'); 11 | } 12 | return null; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/pages/auth/auth.test.tsx: -------------------------------------------------------------------------------- 1 | import App from '@/App'; 2 | import { dummy_login_response } from '@/mocks/server_dummies/auth/get_user_info.response'; 3 | import { stubJSONResponse } from '@apto-payments/test-server'; 4 | import { render, screen, waitFor } from '@testing-library/react'; 5 | import userEvent from '@testing-library/user-event'; 6 | 7 | describe('', () => { 8 | test('it should do a login when the user enters the correct credentials', async () => { 9 | stubJSONResponse({ 10 | method: 'post', 11 | path: '/login', 12 | response: dummy_login_response, 13 | }); 14 | window.history.pushState({}, '', '/auth'); 15 | 16 | render(); 17 | 18 | await waitFor(() => { 19 | expect(screen.getByLabelText('Username')).toBeVisible(); 20 | }); 21 | 22 | userEvent.type(screen.getByLabelText('Username'), 'dummy_username'); 23 | userEvent.type(screen.getByLabelText('Password'), 'dummy_password'); 24 | userEvent.click(screen.getByRole('button', { name: /Log in/i })); 25 | 26 | return waitFor(() => { 27 | expect(screen.getByText('info@iagolast.dev')).toBeVisible(); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/index/index.controller.tsx: -------------------------------------------------------------------------------- 1 | import { authRoute } from '@/pages/auth/auth.route'; 2 | import authService from '@/services/auth.service'; 3 | import { useState } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | export function useIndexPage() { 7 | const navigate = useNavigate(); 8 | const [token] = useState(() => { 9 | const user = authService.getIdToken(); 10 | if (!user) { 11 | return; 12 | } 13 | const jwtToken = decodeJwt(user); 14 | return jwtToken; 15 | }); 16 | 17 | function logout() { 18 | console.info('logout'); 19 | authService.logout(); 20 | navigate(authRoute.path!); 21 | } 22 | 23 | return { 24 | logout, 25 | token, 26 | }; 27 | } 28 | 29 | function decodeJwt(jwtToken: string) { 30 | const base64Url = jwtToken.split('.')[1]; 31 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 32 | const jsonPayload = decodeURIComponent( 33 | atob(base64) 34 | .split('') 35 | .map(function (c) { 36 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 37 | }) 38 | .join('') 39 | ); 40 | 41 | return JSON.parse(jsonPayload); 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/index/index.page.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Heading, Stack, Text } from '@chakra-ui/react'; 2 | import { useIndexPage } from './index.controller'; 3 | 4 | export default function DashboardIndexPage() { 5 | const { logout, token } = useIndexPage(); 6 | 7 | return ( 8 | 9 | 10 | 15 | Todo app build with
16 | 17 | recursive architecture 18 | 19 |
20 | {token.email} 21 | 28 | 31 | 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/index/index.route.tsx: -------------------------------------------------------------------------------- 1 | import { RouteObject } from 'react-router-dom'; 2 | import DashboardPage from './index.page'; 3 | 4 | export const dashboardIndexRoute: RouteObject = { 5 | index: true, 6 | path: '', 7 | Component: DashboardPage, 8 | }; 9 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/index/index.test.tsx: -------------------------------------------------------------------------------- 1 | import App from '@/App'; 2 | import { dummy_login_response } from '@/mocks/server_dummies/auth/get_user_info.response'; 3 | import authService from '@/services/auth.service'; 4 | import { render, screen, waitFor } from '@testing-library/react'; 5 | import userEvent from '@testing-library/user-event'; 6 | 7 | describe('', () => { 8 | beforeEach(() => { 9 | authService.login(dummy_login_response.token); 10 | window.history.pushState({}, '', '/dashboard'); 11 | render(); 12 | }); 13 | 14 | test('should display the logged user', async () => { 15 | return waitFor(() => { 16 | expect(screen.getByText('info@iagolast.dev')).toBeVisible(); 17 | }); 18 | }); 19 | 20 | test('should allow the user to logout', async () => { 21 | await waitFor(() => { 22 | expect(screen.getByRole('button', { name: /Log out/i })).toBeVisible(); 23 | }); 24 | 25 | userEvent.click(screen.getByRole('button', { name: /Log out/i })); 26 | 27 | return waitFor(() => { 28 | expect(authService.isAuthenticated()).toBeFalsy(); 29 | expect(window.location.pathname).toBe('/auth'); 30 | expect(screen.getByRole('button', { name: 'Log in' })).toBeVisible(); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/sidebar/sidebar.component.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Icon, Image, useColorModeValue } from '@chakra-ui/react'; 2 | import { BiHome, BiTask } from 'react-icons/bi'; 3 | import { NavLink } from 'react-router-dom'; 4 | 5 | export default function Sidebar() { 6 | return ( 7 | 35 | 44 | 45 | 46 | 47 | 48 | 59 | 60 | Dashboard 61 | 62 | 63 | 64 | 65 | 76 | 77 | Todos 78 | 79 | 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/todo-details/components/create-todo.controller.tsx: -------------------------------------------------------------------------------- 1 | import { useCreateTodoMutation } from '@/queries/todo.queries'; 2 | import ITodo from '@/types/ITodo'; 3 | import { useToast } from '@chakra-ui/react'; 4 | import { useForm } from 'react-hook-form'; 5 | 6 | interface IUseCreateTodoModalArgs { 7 | onClose: () => void; 8 | } 9 | 10 | export function useCreateTodoModal(args: IUseCreateTodoModalArgs) { 11 | const toast = useToast(); 12 | const createTodoMutation = useCreateTodoMutation(); 13 | 14 | const form = useForm({ 15 | defaultValues: { 16 | id: crypto.randomUUID(), 17 | title: '', 18 | description: '', 19 | completed: false, 20 | }, 21 | }); 22 | 23 | const handleSubmit = form.handleSubmit((values) => { 24 | return createTodoMutation 25 | .mutateAsync({ todo: values }) 26 | .then(() => { 27 | toast({ title: 'Todo created', status: 'success' }); 28 | args.onClose(); 29 | form.reset(); 30 | }) 31 | .catch(() => { 32 | toast({ title: 'Something went wrong', status: 'error' }); 33 | }); 34 | }); 35 | 36 | return { 37 | form, 38 | handleSubmit, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/todo-details/components/create-todo.modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | FormControl, 4 | FormLabel, 5 | Input, 6 | Modal, 7 | ModalBody, 8 | ModalCloseButton, 9 | ModalContent, 10 | ModalFooter, 11 | ModalHeader, 12 | ModalOverlay, 13 | Textarea, 14 | } from '@chakra-ui/react'; 15 | import { useCreateTodoModal } from './create-todo.controller'; 16 | 17 | interface ICreateTodoModalProps { 18 | isOpen: boolean; 19 | onClose: () => void; 20 | } 21 | 22 | export default function CreateTodoModal(props: ICreateTodoModalProps) { 23 | const { handleSubmit, form } = useCreateTodoModal(props); 24 | 25 | return ( 26 | 27 | 28 | 29 | Create a new Todo 30 | 31 | 32 | 33 | Title 34 | 35 | 36 | 37 | 38 | Description 39 |