Compare commits

...

No commits in common. "3708d45fb67c0505e92043b3dda28a4e1cc215c9" and "6b1bb60fbf89a4c48846ce0f8cd1a39b862c823e" have entirely different histories.

56 changed files with 20948 additions and 10 deletions

17
.editorconfig Normal file
View file

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

5
.postcssrc.json Normal file
View file

@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

4
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View file

@ -1,9 +0,0 @@
MIT License
Copyright (c) 2025 rayankonecny
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,2 +1,59 @@
# my-task-board
# MyTaskBoardAngular
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.7.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

91
angular.json Normal file
View file

@ -0,0 +1,91 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"my-task-board-angular": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/my-task-board-angular",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/magenta-violet.css",
"src/styles.css"
],
"scripts": [],
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "my-task-board-angular:build:production"
},
"development": {
"buildTarget": "my-task-board-angular:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-builders/jest:run",
"options": {
"configPath": "./jest.config.js"
}
}
}
}
},
"cli": {
"analytics": "becafa02-4832-4cb2-8fe5-d6838d272b67"
}
}

49
db.json Normal file
View file

@ -0,0 +1,49 @@
{
"tasks": [
{
"id": "39b1",
"title": "Testando",
"categoryId": "1",
"isCompleted": true
},
{
"id": "ab73",
"title": "testando",
"categoryId": "2",
"isCompleted": true
},
{
"id": "eee1",
"title": "Hackeando tudo",
"categoryId": "3",
"isCompleted": true
}
],
"categories": [
{
"id": "1",
"name": "Casa",
"color": "green"
},
{
"id": "2",
"name": "Estudo",
"color": "yellow"
},
{
"id": "3",
"name": "Trabalho",
"color": "blue"
},
{
"id": "4",
"name": "Pessoal",
"color": "red"
},
{
"id": "5",
"name": "Saúde",
"color": "purple"
}
]
}

6
jest.config.js Normal file
View file

@ -0,0 +1,6 @@
const presets = require("jest-preset-angular/presets");
module.exports = {
...presets.createCjsPreset(),
setupFilesAfterEnv: ["<rootDir>/setup-jest.ts"],
};

19040
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

48
package.json Normal file
View file

@ -0,0 +1,48 @@
{
"name": "my-task-board-angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"server": "json-server --watch db.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:watch": "ng test --watch",
"serve:ssr:my-task-board-angular": "node dist/my-task-board-angular/server/server.mjs"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.6",
"@angular/cdk": "^19.2.9",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/material": "^19.2.9",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/platform-server": "^19.2.0",
"@angular/router": "^19.2.0",
"@angular/ssr": "^19.2.7",
"@tailwindcss/postcss": "^4.1.4",
"express": "^4.18.2",
"json-server": "^1.0.0-beta.3",
"postcss": "^8.5.3",
"rxjs": "~7.8.0",
"tailwindcss": "^4.1.4",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular-builders/jest": "^19.0.1",
"@angular-devkit/build-angular": "^19.2.7",
"@angular/cli": "^19.2.7",
"@angular/compiler-cli": "^19.2.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.14",
"@types/node": "^18.18.0",
"jest": "^29.7.0",
"typescript": "~5.7.2",
"zone.js": "^0.15.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

19
public/no_data.svg Normal file
View file

@ -0,0 +1,19 @@
<svg width="200" height="205" viewBox="0 0 200 205" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1347_198)">
<path d="M126.968 45.9364H73.0769C71.8488 45.9379 70.6715 46.449 69.8031 47.3576C68.9347 48.2662 68.4462 49.498 68.4447 50.7829V176.097L67.827 176.294L54.6066 180.53C53.9801 180.73 53.3033 180.662 52.7249 180.34C52.1466 180.018 51.7138 179.47 51.5217 178.814L12.197 44.421C12.0056 43.7655 12.0709 43.0573 12.3783 42.452C12.6858 41.8468 13.2104 41.394 13.8368 41.1933L34.2094 34.6666L93.2704 15.7524L113.643 9.22574C113.953 9.12591 114.279 9.09103 114.602 9.12308C114.925 9.15514 115.238 9.25351 115.525 9.41257C115.811 9.57163 116.065 9.78824 116.272 10.05C116.478 10.3118 116.633 10.6136 116.728 10.9382L126.78 45.2902L126.968 45.9364Z" fill="#F2F2F2"/>
<path d="M138.728 45.2902L126.613 3.88822C126.412 3.19838 126.082 2.55686 125.644 2.00031C125.205 1.44376 124.666 0.9831 124.057 0.64464C123.448 0.306179 122.781 0.096553 122.095 0.0277492C121.409 -0.0410545 120.716 0.032312 120.057 0.243649L91.4144 9.41646L32.3567 28.334L3.71406 37.51C2.38348 37.9375 1.26936 38.8998 0.61626 40.1859C-0.0368417 41.4719 -0.17557 42.9765 0.230533 44.3694L41.6365 185.865C41.9664 186.989 42.6321 187.973 43.5358 188.673C44.4395 189.372 45.5338 189.75 46.6579 189.751C47.1782 189.752 47.6956 189.67 48.1927 189.509L67.8272 183.222L68.4448 183.021V182.346L67.8272 182.543L48.0105 188.892C46.836 189.267 45.5675 189.139 44.4832 188.536C43.3988 187.933 42.5872 186.905 42.2264 185.677L0.823585 44.1788C0.644808 43.5704 0.5825 42.931 0.640226 42.2974C0.697951 41.6637 0.874572 41.0482 1.15998 40.4861C1.4454 39.9239 1.834 39.4262 2.30352 39.0214C2.77304 38.6167 3.31427 38.3127 3.89621 38.1271L32.5388 28.9511L91.5967 10.0368L120.239 0.860776C120.681 0.719839 121.14 0.647979 121.601 0.647529C122.592 0.649855 123.555 0.983927 124.351 1.60081C125.147 2.21769 125.733 3.08497 126.023 4.07562L138.083 45.2903L138.274 45.9365H138.917L138.728 45.2902Z" fill="#3F3D56"/>
<path d="M37.8858 41.2989C37.2905 41.2984 36.7111 41.0984 36.2325 40.7282C35.7539 40.358 35.4013 39.837 35.2264 39.2418L31.2488 25.6484C31.1419 25.2833 31.1048 24.8997 31.1396 24.5195C31.1745 24.1394 31.2805 23.7702 31.4518 23.4329C31.623 23.0957 31.8561 22.797 32.1376 22.554C32.4192 22.311 32.7438 22.1284 33.0928 22.0167L87.4254 4.61331C88.1303 4.38828 88.8916 4.46503 89.5424 4.8267C90.1933 5.18838 90.6803 5.80544 90.8968 6.54244L94.8744 20.136C95.0894 20.8734 95.0159 21.67 94.6703 22.3508C94.3246 23.0317 93.7349 23.5413 93.0306 23.7679L38.6977 41.1712C38.4346 41.2557 38.161 41.2987 37.8858 41.2989Z" fill="#6C63FF"/>
<path d="M58.7223 14.5249C62.1334 14.5249 64.8986 11.6317 64.8986 8.06286C64.8986 4.494 62.1334 1.60086 58.7223 1.60086C55.3112 1.60086 52.546 4.494 52.546 8.06286C52.546 11.6317 55.3112 14.5249 58.7223 14.5249Z" fill="#6C63FF"/>
<path d="M58.7223 12.1548C60.8823 12.1548 62.6334 10.3228 62.6334 8.06286C62.6334 5.80294 60.8823 3.97092 58.7223 3.97092C56.5623 3.97092 54.8113 5.80294 54.8113 8.06286C54.8113 10.3228 56.5623 12.1548 58.7223 12.1548Z" fill="white"/>
<path d="M186.103 188.1H81.7238C81.0279 188.099 80.3607 187.81 79.8686 187.295C79.3765 186.78 79.0997 186.082 79.0989 185.354V54.4985C79.0997 53.7704 79.3765 53.0723 79.8686 52.5574C80.3607 52.0426 81.0279 51.7529 81.7238 51.7521H186.103C186.799 51.753 187.467 52.0426 187.959 52.5575C188.451 53.0723 188.728 53.7704 188.728 54.4985V185.354C188.727 186.082 188.451 186.78 187.959 187.295C187.466 187.81 186.799 188.099 186.103 188.1Z" fill="#E6E6E6"/>
<path d="M138.083 45.2902H73.0769C71.6852 45.2922 70.351 45.8716 69.3669 46.9012C68.3828 47.9308 67.829 49.3267 67.827 50.7829V182.543L68.4447 182.346V50.7829C68.4462 49.498 68.9347 48.2662 69.8031 47.3576C70.6715 46.449 71.8488 45.9379 73.0769 45.9364H138.274L138.083 45.2902ZM194.75 45.2902H73.0769C71.6852 45.2922 70.351 45.8716 69.3669 46.9012C68.3828 47.9308 67.829 49.3267 67.827 50.7829V198.763C67.829 200.219 68.3828 201.615 69.3669 202.644C70.351 203.674 71.6852 204.253 73.0769 204.255H194.75C196.142 204.253 197.476 203.674 198.46 202.644C199.444 201.615 199.998 200.219 200 198.763V50.7829C199.998 49.3267 199.444 47.9308 198.46 46.9012C197.476 45.8716 196.142 45.2922 194.75 45.2902ZM199.382 198.763C199.381 200.048 198.892 201.279 198.024 202.188C197.156 203.096 195.978 203.608 194.75 203.609H73.0769C71.8488 203.608 70.6715 203.096 69.8031 202.188C68.9347 201.279 68.4462 200.048 68.4447 198.763V50.7829C68.4462 49.498 68.9347 48.2662 69.8031 47.3576C70.6715 46.449 71.8488 45.9379 73.0769 45.9364H194.75C195.978 45.9379 197.156 46.449 198.024 47.3576C198.892 48.2662 199.381 49.498 199.382 50.7829V198.763Z" fill="#3F3D56"/>
<path d="M162.325 59.5066H105.503C104.766 59.5057 104.059 59.199 103.538 58.6539C103.017 58.1087 102.724 57.3696 102.723 56.5987V42.3823C102.724 41.6113 103.017 40.8722 103.538 40.327C104.059 39.7819 104.766 39.4752 105.503 39.4744H162.325C163.061 39.4752 163.768 39.7819 164.289 40.327C164.81 40.8722 165.103 41.6113 165.104 42.3823V56.5987C165.103 57.3696 164.81 58.1087 164.289 58.6539C163.768 59.199 163.061 59.5057 162.325 59.5066Z" fill="#6C63FF"/>
<path d="M133.914 40.4437C137.325 40.4437 140.09 37.5505 140.09 33.9817C140.09 30.4128 137.325 27.5197 133.914 27.5197C130.502 27.5197 127.737 30.4128 127.737 33.9817C127.737 37.5505 130.502 40.4437 133.914 40.4437Z" fill="#6C63FF"/>
<path d="M133.914 37.9176C135.991 37.9176 137.675 36.1554 137.675 33.9816C137.675 31.8079 135.991 30.0457 133.914 30.0457C131.836 30.0457 130.152 31.8079 130.152 33.9816C130.152 36.1554 131.836 37.9176 133.914 37.9176Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1347_198">
<rect width="200" height="204.255" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

9
public/trash.svg Normal file
View file

@ -0,0 +1,9 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="34" height="34" fill="url(#pattern0_1347_78)"/>
<defs>
<pattern id="pattern0_1347_78" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_1347_78" transform="scale(0.00195312)"/>
</pattern>
<image id="image0_1347_78" width="512" height="512" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAABHNCSVQICAgIfAhkiAAAAAFzUkdCAK7OHOkAAAAEZ0FNQQAAsY8L/GEFAAAACXBIWXMAAAsQAAALEAGtI711AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAYdpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0n77u/JyBpZD0nVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkJz8+DQo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIj48cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPjxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSJ1dWlkOmZhZjViZGQ1LWJhM2QtMTFkYS1hZDMxLWQzM2Q3NTE4MmYxYiIgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPjx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+PC9yZGY6RGVzY3JpcHRpb24+PC9yZGY6UkRGPjwveDp4bXBtZXRhPg0KPD94cGFja2V0IGVuZD0ndyc/PiyUmAsAAAimSURBVHhe7d2xbcQwFAVB0i0oVaj+K1KoVDXImQ0wNXyGb2fC1wAXP+Ec8Gbu/XjWDX5qu865bvCffawDAPD+BAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAia6/BK93486wYAFdt1/tk77AIAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQNNch4lkHANJy76ELAAAECQAACBIAABAkAAAgSAAAQJAAAIAgAQAAQQIAAIIEAAAECQAACBIAABAkAAAgSAAAQFDu96Mxxrj3w2+AAHzZrjP3HroAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABM114Pfc+/GsGwDftuv0Lr2ICwAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABAkAAAgSAAAQJAAAIEgAAECQAACAIAEAAEECAACCBAAABH0CFS0U/J9eaGoAAAAASUVORK5CYII="/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

3
setup-jest.ts Normal file
View file

@ -0,0 +1,3 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
// setupZoneTestEnv({});

48
src/app/__mocks__/task.ts Normal file
View file

@ -0,0 +1,48 @@
import { Task } from '../features/task/model/task.model';
export const tasks: Task[] = [
{
id: '1',
title: 'Ir na academia',
isCompleted: false,
categoryId: '5',
},
{
id: '2',
title: 'Comprar pão na padaria',
isCompleted: true,
categoryId: '1',
},
];
export const task: Task = {
id: '1',
title: 'Ir na academia',
isCompleted: false,
categoryId: '5',
};
export const TASK_INTERNAL_SERVER_ERROR_RESPONSE: {
status: number;
statusText: string;
} = {
status: 500,
statusText: 'Internal Server Error',
}
export const TASK_UNPROCESSIBLE_ENTITY_RESPONSE: {
status: number;
statusText: string;
} = {
status: 422,
statusText: 'Unprocessable Entity',
};
export const TASK_NOT_FOUND_RESPONSE: {
status: number;
statusText: string;
} = {
status: 404,
statusText: 'Not found',
};

View file

@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { provideHttpClient } from '@angular/common/http';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideHttpClient()],
}).compileComponents();
});
it ('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});

20
src/app/app.component.ts Normal file
View file

@ -0,0 +1,20 @@
import { Component } from '@angular/core';
import { MainComponent } from '@layout/main/main.component';
import { ThemeToggleComponent } from '@shared/components/theme-toggle/theme-toggle.component';
const COMPONENTS = [ThemeToggleComponent, MainComponent];
@Component({
selector: 'app-root',
standalone: true,
imports: [...COMPONENTS],
template: `
<div class="relative min-h-screen w-full">
<app-theme-toggle class="flex justify-end p-4" />
<app-main />
</div>
`,
styles: ``,
})
export class AppComponent {}

View file

@ -0,0 +1,14 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRouting } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
provideServerRouting(serverRoutes)
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

18
src/app/app.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withFetch } from '@angular/common/http'
import { providerThemeInitializer } from './initializers/theme-initializer';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideClientHydration(withEventReplay()),
provideHttpClient(withFetch()),
provideAnimations(),
providerThemeInitializer,
]
};

View file

@ -0,0 +1,8 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Prerender
}
];

3
src/app/app.routes.ts Normal file
View file

@ -0,0 +1,3 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];

View file

@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, inject, Signal } from '@angular/core';
import { MatDivider } from '@angular/material/divider';
import { categoryBackgroundColors } from '@features/category/constants/category-colors';
import { Category } from '@features/category/model/category.model';
import { CategoryService } from '@features/category/services/category.service';
const MODULES = [MatDivider];
@Component({
selector: 'app-colors-list',
standalone: true,
imports: [...MODULES],
template: `
<section class="flex flex-col gap-4 w-full h-auto mb-4">
<!-- Divisor -->
<mat-divider class="opaction-50" />
<!-- Lista de Cores -->
<div class="flex flex-wrap justify-center items-center mt-4 px-2 gap-4">
@for (category of categories(); track category.id) {
<span
class="select-none opacity-80 hover:opacity-100 flex items-center justify-center
{{categoryBackgroundColors[category.color]}}
px-4 py-2 rounded-xl w-[90px] text-center text-white font-semibold">
{{ category.name }}
</span>
}
</div>
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ColorsListComponent {
private readonly categoryService = inject(CategoryService);
public readonly categories: Signal<Category[]> =
this.categoryService.categories;
public categoryBackgroundColors = categoryBackgroundColors;
}

View file

@ -0,0 +1,30 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, Signal } from '@angular/core';
import { Category } from '@category/model/category.model';
import { CategoryService } from '@category/services/category.service';
@Component({
selector: 'app-main-list',
standalone: true,
imports: [CommonModule],
template: `
<section class="mt-16 mx-12 pl-8">
<span class="text-2xl font-semibold">Categorias</span>
<ul class="mt-4 space-y-4">
@for(category of categories(); track category.id) {
<li class="text-2xl font-medium">{{ category.name }}</li>
}
</ul>
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MainListComponent {
private readonly categoryService = inject(CategoryService);
public readonly categories: Signal<Category[]> =
this.categoryService.categories;
}

View file

@ -0,0 +1,25 @@
export const categoryBackgroundColors: Record<string, string> = {
green: 'bg-green-600',
yellow: 'bg-yellow-600',
red: 'bg-red-600',
blue: 'bg-blue-600',
purple: 'bg-purple-600',
};
export const categoryBackgroundIdColors: Record<string, string> = {
'1': 'bg-green-600',
'2': 'bg-yellow-600',
'3': 'bg-red-600',
'4': 'bg-blue-600',
'5': 'bg-purple-600',
};
export const categoryTextColors: Record<string, string> = {
green: 'text-green-600',
yellow: 'text-yellow-600',
red: 'text-red-600',
blue: 'text-blue-600',
purple: 'text-purple-600',
};

View file

@ -0,0 +1,5 @@
export type Category = {
id: string;
name: string;
color: string;
};

View file

@ -0,0 +1,22 @@
import { inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Category } from '../model/category.model';
import { environment } from '../../../../env/environment.prod';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable({
providedIn: 'root',
})
export class CategoryService {
private readonly httpClient = inject(HttpClient);
private categories$ = this.httpClient.get<Category[]>(
`${environment.apiUrl}/categories`
);
public categories = toSignal(this.categories$, {
initialValue: [] as Category[],
});
public selectedCategoryId = signal('1')
}

View file

@ -0,0 +1,30 @@
import {
ChangeDetectionStrategy,
Component,
inject
} from '@angular/core';
import { ColorsListComponent } from '../../components/colors-list/colors-list.component';
import { MainListComponent } from '../../components/main-list/main-list.component';
import { CategoryService } from '../../services/category.service';
const COMPONENTS = [MainListComponent, ColorsListComponent];
@Component({
selector: 'app-category',
standalone: true,
imports: [...COMPONENTS],
template: `
<div class="flex flex-col justify-between h-full w-full">
<!-- Main List -->
<app-main-list />
<!-- Colors List -->
<app-colors-list />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CategoryComponent {
private readonly categoryService = inject(CategoryService);
}

View file

@ -0,0 +1,156 @@
import { SnackBarService } from '@shared/services/snack-bar.service';
import { TaskService } from '@features/task/service/task.service';
import { CategoryService } from '@features/category/services/category.service';
import { Task } from '@features/task/model/task.model';
import { task } from '@mocks/task';
import { Observable, of } from 'rxjs';
import { By } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideAnimations } from '@angular/platform-browser/animations';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { IncludeTaskFormComponent } from './include-task-form.component';
describe('IncludeTaskFormComponent', () => {
let component: IncludeTaskFormComponent;
let fixture: ComponentFixture<IncludeTaskFormComponent>;
let categoryService: CategoryService;
let taskService: TaskService;
let snackBarService: SnackBarService;
let createTaskSpy: jest.SpyInstance<Observable<Task>>;
const MOCKED_TASK = task;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [IncludeTaskFormComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideAnimations(),
],
}).compileComponents();
fixture = TestBed.createComponent(IncludeTaskFormComponent);
categoryService = TestBed.inject(CategoryService);
taskService = TestBed.inject(TaskService);
snackBarService = TestBed.inject(SnackBarService);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('creates a component', () => {
expect(component).toBeTruthy();
});
describe('visibility', () => {
it('render initial newTaskForm state value', () => {
const newTaskForm = component.newsTaskForm;
expect(newTaskForm.controls.title.value).toEqual('');
expect(newTaskForm.controls.categoryId.value).toEqual('1');
});
it('render initial newsTaskForm label values', () => {
const titleLabel = fixture.debugElement.query(
By.css('[data-testid="title"')
);
const categoryLabel = fixture.debugElement.query(
By.css('[data-testid="categoryId"')
);
expect(titleLabel.nativeElement.textContent).toContain('Tarefa');
expect(categoryLabel.nativeElement.textContent).toContain('Categoria');
});
});
it('should be able call selectionChangeHandler when mat-select dispatch selectionChange event', () => {
const categoryId = '3';
const event = { value: categoryId };
const selectionChangeHandlerSpy = jest
.spyOn(component, 'selectionChangeHandler')
.mockImplementationOnce(() => {});
fixture.debugElement
.query(By.css('mat-select'))
.triggerEventHandler('selectionChange', event);
expect(selectionChangeHandlerSpy).toHaveBeenCalledWith(event);
});
it('should be able call snackBarConfigHandler when is called with a message', () => {
const message = 'Tarefa incluída!';
const snowSnackBarSpy = jest
.spyOn(snackBarService, 'showSnackBar')
.mockImplementationOnce(() => {});
component.snackBarConfigHandler(message);
expect(snowSnackBarSpy).toHaveBeenCalledWith(message, 4000, 'end', 'top');
});
it('should be able enable/disable newTaskForm and set isIncludeTaskFormDisabled when taskService.isLoading is true or false', () => {
const newTaskForm = component.newsTaskForm;
taskService.isLoadingTask.set(true);
expect(component.isIncludeTaskFormDisabled()).toBeTruthy();
expect(newTaskForm.disabled).toBeTruthy();
taskService.isLoadingTask.set(false);
expect(component.isIncludeTaskFormDisabled()).toBeFalsy();
expect(newTaskForm.disabled).toBeFalsy();
});
describe('onEnterToAddATask', () => {
it('shouldn`t be able do nothing when newTaskForm is invalid', () => {
component.onEnterToaddATask();
const createTaskSpy = jest
.spyOn(taskService, 'createTask')
.mockReturnValue(of(MOCKED_TASK));
expect(createTaskSpy).not.toHaveBeenCalled();
expect(component.isIncludeTaskFormDisabled()).toBeFalsy();
});
it('should be able create task when newTaskForm is valid', fakeAsync(() => {
component.newsTaskForm.controls.title.setValue(MOCKED_TASK.title);
component.newsTaskForm.controls.categoryId.setValue(
MOCKED_TASK.categoryId
);
const createTaskSpy = jest
.spyOn(taskService, 'createTask')
.mockReturnValue(of(MOCKED_TASK));
const inserATaskInTheTasksListSpy = jest
.spyOn(taskService, 'insertATaskInTheTasksList')
.mockImplementation(() => {});
const snackBarConfigHandlerSpy = jest
.spyOn(component, 'snackBarConfigHandler')
.mockImplementation(() => {});
component.onEnterToaddATask();
tick(4000);
expect(createTaskSpy).toHaveBeenCalled();
expect(inserATaskInTheTasksListSpy).toHaveBeenCalledWith(MOCKED_TASK);
expect(snackBarConfigHandlerSpy).toHaveBeenCalledWith(
'Tarefa concluida!'
);
}));
});
});

View file

@ -0,0 +1,133 @@
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectChange, MatSelectModule } from '@angular/material/select';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { delay, finalize } from 'rxjs/operators';
import { NgClass } from '@angular/common';
import { CategoryService } from '@category/services/category.service';
import { SnackBarService } from '@shared/services/snack-bar.service';
import { createTaskForm } from '@features/task/constants/create-task-form';
import { Task } from '@features/task/model/task.model';
import { TaskService } from '@features/task/service/task.service';
const MODULES = [
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
FormsModule,
ReactiveFormsModule,
];
const COMMONS = [NgClass];
@Component({
selector: 'app-include-task-form',
standalone: true,
imports: [...COMMONS, ...MODULES],
template: `
<form
[ngClass]="{
'cursor-not-allowed animate-pulse': isIncludeTaskFormDisabled(),
'cursor-pointer': !isIncludeTaskFormDisabled()
}"
autocomplete="off"
class="flex flex-row gap-2 select-none"
[formGroup]="newsTaskForm"
>
<mat-form-field class="w-full">
<mat-label for="title" data-testid="title">Tarefas</mat-label>
<input
formControlName="title"
matInput
placeholder="Adicionar tarefa"
(keyup.enter)="onEnterToaddATask()"
/>
<mat-hint class="text-tertiary">Aperte enter para adicionar</mat-hint>
</mat-form-field>
<mat-form-field>
<mat-label for="categoryId" data-testid="categoryId"
>Categorias</mat-label>
<mat-select
data-testid="matSelect"
formControlName="categoryId"
(selectionChange)="selectionChangeHandler($event)"
(keyup.enter)="onEnterToaddATask()"
>
@for(category of categories(); track category.id) {
<mat-option value="{{ category.id }}">{{ category.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</form>
`,
styles: ``,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IncludeTaskFormComponent {
private readonly categoryService = inject(CategoryService);
private readonly taskService = inject(TaskService);
private readonly snackBarService = inject(SnackBarService);
private readonly destroy$ = inject(DestroyRef);
public readonly categories = this.categoryService.categories;
public readonly newsTaskForm = createTaskForm();
public selectionChangeHandler(event: MatSelectChange): void {
const categoryId = event.value;
this.categoryService.selectedCategoryId.set(categoryId);
}
public onEnterToaddATask(): void {
if (this.newsTaskForm.invalid) return;
this.taskService.isLoadingTask.set(true);
const { categoryId, title } = this.newsTaskForm.value;
const newTask: Partial<Task> = {
title,
categoryId,
isCompleted: false,
};
this.taskService
.createTask(newTask)
.pipe(
delay(4000),
finalize(() => this.taskService.isLoadingTask.set(false)),
takeUntilDestroyed(this.destroy$)
)
.subscribe({
next: (task) => this.taskService.insertATaskInTheTasksList(task),
error: (error) => {
this.snackBarConfigHandler(error.message);
},
complete: () => this.snackBarConfigHandler('Tarefa concluida!'),
});
}
public isIncludeTaskFormDisabled = computed(() => {
if (this.taskService.isLoadingTask()) {
this.newsTaskForm.disable();
return this.taskService.isLoadingTask();
}
this.newsTaskForm.enable();
return this.taskService.isLoadingTask();
});
public snackBarConfigHandler(message: string): void {
this.snackBarService.showSnackBar(message, 4000, 'end', 'top');
}
}

View file

@ -0,0 +1,42 @@
import { Component, inject } from '@angular/core';
import { IncludeTaskFormComponent } from './include-task-form/include-task-form.component';
import { NgClass } from '@angular/common';
import { categoryBackgroundIdColors } from '@features/category/constants/category-colors';
import { CategoryService } from '@features/category/services/category.service';
import { TaskService } from '@features/task/service/task.service';
const COMPONENTS = [IncludeTaskFormComponent]
const COMMONS = [NgClass];
@Component({
selector: 'app-inclusion-form',
imports: [...COMPONENTS, ...COMMONS],
template: `
<div class="grid grid-cols-12 gap-2 mt-8">
<app-include-task-form class="col-span-11"/>
<div class="colspan-1 flex items-start mt-2">
<span
[ngClass]="{
'opacity-30': taskService.isLoadingTask(),
'opacity-100': !taskService.isLoadingTask()
}"
class=" {{
colorVariants[selectedCategoryId()]
}} rounded-full w-10 h-10 bg-blue-700"
></span>
</div>
</div>
`,
standalone: true,
styles: ``,
})
export class InclusionFormComponent {
private readonly categoryService = inject(CategoryService);
public readonly taskService = inject(TaskService);
public readonly selectedCategoryId = this.categoryService.selectedCategoryId;
public colorVariants = categoryBackgroundIdColors;
}

View file

@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-no-task',
standalone: true,
template: `
<div class="flex flex-col w-full justify-center items-center mt-8">
<img width="200" height="200" [alt]="alt" [src]="imageUrl" />
<span class="text-xl text-primary font-bold">{{ message }}</span>
</div>
`,
styles: ``,
})
export class NoTasksComponent {
@Input() public alt!: string;
@Input() public imageUrl!: string;
@Input() public message!: string;
}

View file

@ -0,0 +1,77 @@
import { Component, inject } from '@angular/core';
import { TaskService } from '@features/task/service/task.service';
import { AsyncPipe } from '@angular/common';
import { NoTasksComponent } from '../no-tasks/no-tasks.component';
import { Task } from '@features/task/model/task.model';
import { CategoryService } from '@features/category/services/category.service';
import { categoryTextColors } from '@features/category/constants/category-colors';
@Component({
selector: 'app-task-list',
imports: [AsyncPipe, NoTasksComponent],
template: `
<div class="mt-8">
@if(tasks$ | async) { @if(numberOfTasks() > 0) { @for(task of tasks();
track task.id) {
<div class="flex flex-row justify-start mb-4 items-center gap-4 ">
<!-- Checkbox -->
<input
type="checkbox"
[checked]="task.isCompleted"
(change)="toggleTaskStatus(task)"
class="form-checkbox w-5 h-5 accent-purple-500"
/>
<!-- Título da tarefa -->
@if(task.isCompleted) {
<label class="line-through {{ colorVariants[getColorByTask(task)] }}">{{task.title}}</label>
}@else{
<label class="{{ colorVariants[getColorByTask(task)] }}">{{task.title}}</label>
}
<!-- Botão de deletar -->
<button (click)="deleteTask(task.id)">
<img src="trash.svg" />
</button>
</div>
} } @else {
<app-no-task
alt="Nenhuma tarefa adicionada"
imageUrl="no_data.svg"
message="Nenhuma tarefa adicionada 😢"
/>
} }
</div>
`,
styles: ``,
})
export class TaskListComponent {
private tasksService = inject(TaskService);
private categoryService = inject(CategoryService);
public tasks$ = this.tasksService.getTasks();
public tasks = this.tasksService.tasks;
public category = this.categoryService.categories;
public toggleTaskStatus(task: Task): void {
this.tasksService
.updateIsCompletedStatus(task.id, !task.isCompleted)
.subscribe();
}
// função para buscar cor com base na categoria da task
public getColorByTask(task: Task): string {
const cat = this.category().find(
(cat) => cat.id === task.categoryId
)?.color;
return cat || '';
}
public numberOfTasks = this.tasksService.numberOfTasks;
public colorVariants = categoryTextColors;
public updateTask = this.tasksService.updateTaskInTheTasksList;
public deleteTask(taskId: string): void {
this.tasksService.deleteTask(taskId).subscribe();
}
}

View file

@ -0,0 +1,26 @@
import { inject } from "@angular/core";
import { FormControl, FormGroup, NonNullableFormBuilder, Validators } from "@angular/forms";
type TaskFormControl = {
title: FormControl<string>;
categoryId: FormControl<string>;
};
export function createTaskForm(): FormGroup<TaskFormControl> {
const formBuilder = inject(NonNullableFormBuilder);
return formBuilder.group({
title: new FormControl('', {
validators: [Validators.required, Validators.minLength(3)],
nonNullable: true,
}),
categoryId: new FormControl('1', {
validators: [Validators.required],
nonNullable: true,
}),
});
}
export type TaskFormGroup = ReturnType<typeof createTaskForm>;
export type TaskFormValue = ReturnType<TaskFormGroup['getRawValue']>;

View file

@ -0,0 +1,6 @@
export type Task = {
id: string;
title: string;
isCompleted: boolean;
categoryId: string;
}

View file

@ -0,0 +1,271 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { HttpErrorResponse, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TaskService } from './task.service';
import { Task } from '../model/task.model';
import { task, TASK_INTERNAL_SERVER_ERROR_RESPONSE, TASK_UNPROCESSIBLE_ENTITY_RESPONSE, tasks } from '@mocks/task';
describe('TaskService', () => {
let taskService: TaskService;
let httpTestingController: HttpTestingController;
const MOCKED_TASKS = tasks;
const MOCKED_TASK = task;
const baseURL = 'http://localhost:3000';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
taskService = TestBed.inject(TaskService);
httpTestingController = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTestingController.verify();
});
it('should be created ', () => {
expect(taskService).toBeTruthy();
});
it('should get tasks', () => {
const sortedTasks = taskService.getSortedTasks(MOCKED_TASKS);
expect(sortedTasks[0].title).toEqual('Comprar pão na padaria');
});
describe('getTasks', () => {
it('should return a list of tasks', waitForAsync(() => {
taskService.getTasks().subscribe((response) => {
expect(response).toEqual(MOCKED_TASKS);
expect(taskService.tasks()).toEqual(MOCKED_TASKS);
});
const req = httpTestingController.expectOne(`${baseURL}/tasks`);
req.flush(MOCKED_TASKS);
expect(taskService.tasks()).toEqual(MOCKED_TASKS);
expect(req.request.method).toEqual('GET');
}));
it('should throw and error when server return Internal Server Error', waitForAsync(() => {
let httpErrorResponse: HttpErrorResponse | undefined;
taskService.getTasks().subscribe({
next: () => {
fail('failed to fetch the tasks list');
},
error: (error: HttpErrorResponse) => {
httpErrorResponse = error;
},
});
const req = httpTestingController.expectOne(`${baseURL}/tasks`);
req.flush('Internal Server Error', TASK_INTERNAL_SERVER_ERROR_RESPONSE);
if (!httpErrorResponse) {
throw new Error('Error neets to be defined');
}
expect(httpErrorResponse.status).toEqual(500);
expect(httpErrorResponse.statusText).toEqual('Internal Server Error');
}));
});
describe('creatTask', () => {
it('should create a new task with waitForAsync', waitForAsync(() => {
let task: Task | undefined;
taskService.createTask(MOCKED_TASK).subscribe((response) => {
task = response
});
const req = httpTestingController.expectOne(`${baseURL}/tasks`);
req.flush(MOCKED_TASK);
expect(task).toEqual(MOCKED_TASK);
expect(req.request.method).toEqual('POST');
}));
it('should throw unprocessible entity with invalid body when create a task', waitForAsync(() => {
let httpErrorResponse: HttpErrorResponse | undefined;
taskService.createTask(MOCKED_TASK).subscribe({
next: () => {
fail('failed to fetch a new task');
},
error: (error: HttpErrorResponse) => {
httpErrorResponse = error;
},
});
const req = httpTestingController.expectOne(`${baseURL}/tasks`);
req.flush('Unprocessable Entity', TASK_UNPROCESSIBLE_ENTITY_RESPONSE);
if (!httpErrorResponse) {
throw new Error('Error neets to be defined');
}
expect(httpErrorResponse.status).toEqual(422);
expect(httpErrorResponse.statusText).toEqual('Unprocessable Entity');
}));
});
describe('updateTask', () => {
it('should update a task', waitForAsync(() => {
taskService.tasks.set([MOCKED_TASK]);
const updatedTask = MOCKED_TASK;
updatedTask.title = 'Ir na academia treinar perna';
taskService.updateTask(updatedTask).subscribe(() => {
expect(taskService.tasks()[0].title).toEqual(
'Ir na academia treinar perna'
);
});
const req = httpTestingController.expectOne(
`${baseURL}/tasks/${updatedTask.id}`
);
req.flush(MOCKED_TASK);
expect(req.request.method).toEqual('PUT');
}));
it('should throw unprocessible entity with invalid body when update a task', waitForAsync(() => {
let httpErrorResponse: HttpErrorResponse | undefined;
taskService.tasks.set([MOCKED_TASK]);
const updatedTask = MOCKED_TASK;
updatedTask.title = 'Ir na academia treinar perna';
taskService.updateTask(MOCKED_TASK).subscribe({
next: () => {
fail('failed to update a task');
},
error: (error: HttpErrorResponse) => {
httpErrorResponse = error;
},
});
const req = httpTestingController.expectOne(
`${baseURL}/tasks/${updatedTask.id}`
);
req.flush('Unprocessable Entity', TASK_UNPROCESSIBLE_ENTITY_RESPONSE);
if (!httpErrorResponse) {
throw new Error('Error neets to be defined');
}
expect(httpErrorResponse.status).toEqual(422);
expect(httpErrorResponse.statusText).toEqual('Unprocessable Entity');
}));
});
describe('updateIsCompletedStatus', () => {
it('should update IsCompletedStatus of a task', waitForAsync(() => {
const updatedTask = MOCKED_TASK;
const methodUrl = `${baseURL}/tasks/${updatedTask.id}`;
taskService.tasks.set(MOCKED_TASKS);
taskService
.updateIsCompletedStatus(MOCKED_TASK.id, true)
.subscribe(() => {
expect(taskService.tasks()[0].isCompleted).toBeTruthy();
});
const req = httpTestingController.expectOne(methodUrl);
req.flush({ isCompleted: true });
expect(req.request.method).toEqual('PATCH');
}));
it('should throw and error when update a tasks isCompleted status', waitForAsync(() => {
let httpErrorResponse: HttpErrorResponse | undefined;
const updatedTask = MOCKED_TASK;
const methodUrl = `${baseURL}/tasks/${updatedTask.id}`;
taskService.tasks.set(MOCKED_TASKS);
taskService.updateIsCompletedStatus(updatedTask.id, true).subscribe({
next: () => {
fail('failed to update a task isCompleted status');
},
error: (error: HttpErrorResponse) => {
httpErrorResponse = error;
},
});
const req = httpTestingController.expectOne(methodUrl);
req.flush('Unprocessable Entity', TASK_UNPROCESSIBLE_ENTITY_RESPONSE);
if (!httpErrorResponse) {
throw new Error('Error neets to be defined');
}
expect(httpErrorResponse.status).toEqual(422);
expect(httpErrorResponse.statusText).toEqual('Unprocessable Entity');
}));
});
describe('deleteTask', () => {
it('should delete a task', waitForAsync(() => {
taskService.tasks.set([MOCKED_TASK]);
taskService.deleteTask(MOCKED_TASK.id).subscribe(() => {
expect(taskService.tasks().length).toEqual(0);
});
const req = httpTestingController.expectOne(
`${baseURL}/tasks/${MOCKED_TASK.id}`
);
req.flush(null);
expect(req.request.method).toEqual('DELETE');
}));
it('should throw unprocessible entity with invalid body when delete a task', waitForAsync(() => {
let httpErrorResponse: HttpErrorResponse | undefined;
taskService.tasks.set([MOCKED_TASK]);
taskService.deleteTask(MOCKED_TASK.id).subscribe({
next: () => {
fail('failed to delete a task');
},
error: (error: HttpErrorResponse) => {
httpErrorResponse = error;
},
});
const req = httpTestingController.expectOne(
`${baseURL}/tasks/${MOCKED_TASK.id}`
);
req.flush('Unprocessable Entity', TASK_UNPROCESSIBLE_ENTITY_RESPONSE);
if (!httpErrorResponse) {
throw new Error('Error neets to be defined');
}
expect(httpErrorResponse.status).toEqual(422);
expect(httpErrorResponse.statusText).toEqual('Unprocessable Entity');
}));
});
});

View file

@ -0,0 +1,88 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { environment } from '@env/environment.prod';
import { Task } from '../model/task.model';
@Injectable({
providedIn: 'root',
})
export class TaskService {
private readonly httpClient = inject(HttpClient);
private readonly _apiUrl = environment.apiUrl;
public readonly tasks = signal<Task[]>([]);
public readonly numberOfTasks = computed(() => this.tasks().length);
public isLoadingTask = signal(false);
public getTasks(): Observable<Task[]> {
return this.httpClient.get<Task[]>(`${this._apiUrl}/tasks`).pipe(
tap((tasks) => {
const sortedTasks = this.getSortedTasks(tasks);
this.tasks.set(sortedTasks);
})
);
}
public getSortedTasks(tasks: Task[]): Task[] {
return tasks.sort((a, b) => a.title?.localeCompare(b.title));
}
public createTask(task: Partial<Task>): Observable<Task> {
return this.httpClient
.post<Task>(`${this._apiUrl}/tasks`, task)
}
public insertATaskInTheTasksList(newTask: Task): void {
this.tasks.update(tasks => {
const newTasksList = [...tasks, newTask];
return this.getSortedTasks(newTasksList);
});
}
public insertATasksList(newTask: Task): void {
const updatedTasks = [...this.tasks(), newTask];
const sortedTasks = this.getSortedTasks(updatedTasks);
this.tasks.set(sortedTasks);
}
public updateTask(updatedTask: Task): Observable<Task> {
return this.httpClient
.put<Task>(`${this._apiUrl}/tasks/${updatedTask.id}`, updatedTask)
.pipe(tap((tasks) => this.updateTaskInTheTasksList(tasks)));
}
public updateIsCompletedStatus(
taskId: string,
isCompleted: boolean
): Observable<Task> {
return this.httpClient
.patch<Task>(`${this._apiUrl}/tasks/${taskId}`, { isCompleted })
.pipe(tap((tasks) => this.updateTaskInTheTasksList(tasks)));
}
public updateTaskInTheTasksList(updatedTask: Task): void {
this.tasks.update((tasks) => {
const allTasksWithUpdatedTaskRemoved = tasks.filter(
(task) => task.id !== updatedTask.id
);
const updatedTaskList = [...allTasksWithUpdatedTaskRemoved, updatedTask];
return this.getSortedTasks(updatedTaskList);
});
}
public deleteTask(taskId: string): Observable<Task> {
return this.httpClient
.delete<Task>(`${this._apiUrl}/tasks/${taskId}`)
.pipe(tap(() => this.deleteATaksInTheTasksList(taskId)));
}
public deleteATaksInTheTasksList(taskId: string): void {
this.tasks.update((tasks) => tasks.filter((task) => task.id !== taskId));
}
}

View file

@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { InclusionFormComponent } from '../../components/inclusion-form/inclusion-form.component';
import { TaskListComponent } from "../../components/task-list/task-list.component";
const COMPONENTS = [InclusionFormComponent, TaskListComponent];
@Component({
selector: 'app-task',
standalone: true,
imports: [...COMPONENTS ],
template: ` <div class="flex flex-col mx-10">
<!-- Titulo -->
<span class="font-bold text-4xl">Meu quadro de tarefas</span>
<!-- Formulario -->
<app-inclusion-form />
<!-- Lista de tarefas -->
<app-task-list/>
</div>`,
styles: ``,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskComponent {}

View file

@ -0,0 +1,19 @@
import { EnvironmentProviders, inject, Provider } from '@angular/core';
import { ThemeService } from '../shared/services/theme.service';
export function ThemeInitializer() {
return () => {
const themeService = inject(ThemeService);
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark';
themeService.setTheme(savedTheme || 'light');
};
}
export const providerThemeInitializer: Provider | EnvironmentProviders = {
provide: 'APP_INITIALIZER',
useFactory: ThemeInitializer,
multi: true,
deps: [ThemeService],
};

View file

@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatDivider } from '@angular/material/divider';
import { CategoryComponent } from '@features/category/view/category/category.component';
import { TaskComponent } from '@features/task/view/task/task.component';
const COMPONENTS = [CategoryComponent, TaskComponent];
const MODULES = [MatDivider];
@Component({
selector: 'app-main',
standalone: true,
imports: [...COMPONENTS,...MODULES],
template: `
<div class="h-screen flex w-full border-4">
<!-- Categorias -->
<app-category class="w-1/4 opacity-50" />
<!-- Divisor -->
<mat-divider class="h-full border-2" />
<!-- Tarefas -->
<app-task class="w-3/4 border-2 pt-10" />
</div>
`,
styles: ``,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MainComponent {}

View file

@ -0,0 +1,20 @@
import { Component, inject } from '@angular/core';
import { ThemeService } from '@shared/services/theme.service';
@Component({
selector: 'app-theme-toggle',
template: `
<button (click)="toggleTheme()">
{{ themeService.getCurrentTheme() === 'dark' ? '🌙 Dark' : '☀️ Light' }}
</button>
`,
styles: ``,
})
export class ThemeToggleComponent {
public themeService = inject(ThemeService);
toggleTheme() {
this.themeService.toggleTheme();
}
}

View file

@ -0,0 +1,38 @@
import { inject, Injectable, signal } from '@angular/core';
import { MatSnackBar, MatSnackBarHorizontalPosition, MatSnackBarVerticalPosition } from '@angular/material/snack-bar'
@Injectable({
providedIn: 'root',
})
export class SnackBarService {
public message = signal('');
private _snackBar = inject(MatSnackBar);
public durationInMiliSeconds = 3000;
public horizontalPosition: MatSnackBarHorizontalPosition = 'end';
public verticalPosition: MatSnackBarVerticalPosition = 'top';
public showSnackBar(
message: string,
duration: number,
horizontalPosition: MatSnackBarHorizontalPosition,
verticalPosition: MatSnackBarVerticalPosition
): void {
this.message.set(message);
this.durationInMiliSeconds = duration;
this.horizontalPosition = horizontalPosition;
this.verticalPosition = verticalPosition;
this.openSnackBar();
};
private openSnackBar(): void {
this._snackBar.open(this.message(), '❌', {
duration: this.durationInMiliSeconds,
horizontalPosition: this.horizontalPosition,
verticalPosition: this.verticalPosition,
});
}
}

View file

@ -0,0 +1,59 @@
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
type Theme = 'light' | 'dark';
@Injectable({
providedIn: 'root',
})
export class ThemeService {
private currentTheme: Theme = 'light';
private isBrowser: boolean;
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
this.isBrowser = isPlatformBrowser(this.platformId);
if (this.isBrowser) {
const savedTheme = localStorage.getItem('theme') as Theme | null;
if (savedTheme === 'light' || savedTheme === 'dark') {
this.setTheme(savedTheme);
} else {
this.setTheme(this.currentTheme);
}
}
}
/**
* Alterna entre os temas 'light' e 'dark'.
*/
toggleTheme(): void {
const newTheme: Theme = this.currentTheme === 'light' ? 'dark' : 'light';
this.setTheme(newTheme);
}
/**
* Aplica o tema na tag <body>, salva no localStorage e atualiza o estado atual.
* @param theme 'light' | 'dark'
*/
public setTheme(theme: Theme): void {
if (!this.isBrowser) return;
// Evita reaplicar o mesmo tema
if (this.currentTheme === theme) return;
const body = document.body;
body.classList.remove('light-theme', 'dark-theme');
body.classList.add(`${theme}-theme`);
localStorage.setItem('theme', theme);
this.currentTheme = theme;
}
/**
* Retorna o tema atual da aplicação.
*/
getCurrentTheme(): Theme {
return this.currentTheme;
}
}

4
src/env/environment.prod.ts vendored Normal file
View file

@ -0,0 +1,4 @@
export const environment = {
production: true,
apiUrl: 'http://localhost:3000',
};

4
src/env/environment.ts vendored Normal file
View file

@ -0,0 +1,4 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:3000',
};

15
src/index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MyTaskBoardAngular</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

7
src/main.server.ts Normal file
View file

@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = () => bootstrapApplication(AppComponent, config);
export default bootstrap;

5
src/main.ts Normal file
View file

@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));

66
src/server.ts Normal file
View file

@ -0,0 +1,66 @@
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
/**
* Example Express Rest API endpoints can be defined here.
* Uncomment and define endpoints as necessary.
*
* Example:
* ```ts
* app.get('/api/**', (req, res) => {
* // Handle API request
* });
* ```
*/
/**
* Serve static files from /browser
*/
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
}),
);
/**
* Handle all other requests by rendering the Angular application.
*/
app.use('/**', (req, res, next) => {
angularApp
.handle(req)
.then((response) =>
response ? writeResponseToNodeResponse(response, res) : next(),
)
.catch(next);
});
/**
* Start the server if this module is the main entry point.
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
*/
if (isMainModule(import.meta.url)) {
const port = process.env['PORT'] || 4000;
app.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
/**
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
*/
export const reqHandler = createNodeRequestHandler(app);

24
src/styles.css Normal file
View file

@ -0,0 +1,24 @@
/* You can add global styles to this file, and also import other style files */
@import "tailwindcss";
@import "./styles/overrides.css";
/* Tema claro */
.light-theme {
--background-color: #ffffff;
--text-color: #000000;
}
/* Tema escuro */
.dark-theme {
--background-color: #121212;
--text-color: #ffffff;
}
body {
background-color: var(--background-color);
color: var(--text-color);
transition: background-color 0.3s, color 0.3s;
}
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }

4
src/styles/overrides.css Normal file
View file

@ -0,0 +1,4 @@
.mat-mdc-form-field-hint-wrapper {
padding: 0px !important;
margin: 0px !important;
}

19
tsconfig.app.json Normal file
View file

@ -0,0 +1,19 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": [
"node"
]
},
"files": [
"src/main.ts",
"src/main.server.ts",
"src/server.ts"
],
"include": [
"src/**/*.d.ts"
]
}

43
tsconfig.json Normal file
View file

@ -0,0 +1,43 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"types": [
"node",
"jest"
],
"baseUrl": "./",
"paths": {
"@features/*": ["src/app/features/*"],
"@category/*": ["src/app/features/category/*"],
"@task/*": ["src/app/features/task/*"],
"@mocks/*": ["src/app/__mocks__/*"],
"@shared/*": ["src/app/shared/*"],
"@layout/*": ["src/app/layout/*"],
"@initializers/*": ["src/app/initializers/*"],
"@services/*": ["src/app/services/*"],
"@env/*": ["src/env/*"]
},
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

15
tsconfig.spec.json Normal file
View file

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jest"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}