Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4217ea11c8 | ||
|
|
f41e34486a | ||
|
|
29812e8175 | ||
|
|
0444ad5a5d | ||
|
|
89eb15d714 | ||
|
|
08ed7c485e | ||
|
|
cf598c0971 | ||
|
|
e8833320d4 | ||
|
|
eb759a232d | ||
|
|
4e167f9a85 | ||
|
|
ee2fb62036 | ||
|
|
5eb9ee5ced | ||
|
|
2679cf61ac | ||
|
|
49baf4db77 | ||
|
|
c1db12be40 | ||
|
|
1eab863fca | ||
|
|
ed6738381a | ||
|
|
b423082b1f | ||
|
|
ab34a310c4 | ||
|
|
ee916d02b9 | ||
|
|
ee38fa256d | ||
|
|
1d7b10ec4d | ||
|
|
e4454f913f | ||
|
|
7ea127c4a9 | ||
|
|
7fffb163d1 | ||
|
|
bd3a6c6f18 | ||
|
|
111eb4686b | ||
|
|
da0432a734 | ||
|
|
d016d20850 | ||
|
|
332da7de94 | ||
|
|
6c92a5ca91 |
@@ -0,0 +1,51 @@
|
||||
name: React App CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'client/**/*.html'
|
||||
- 'client/**/*.png'
|
||||
- 'client/**/*.json'
|
||||
- 'client/**/*.txt'
|
||||
- 'client/**/*.ts'
|
||||
- 'client/**/*.tsx'
|
||||
- 'client/**/*.js'
|
||||
- 'client/Dockerfile'
|
||||
- 'client/Caddyfile'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:no-watch
|
||||
working-directory: ./client
|
||||
@@ -74,3 +74,6 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Trigger Dokploy deployment
|
||||
run: curl -X POST "${{ vars.DOKPLOY_WEBHOOK_APP }}"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
|
||||
- name: Increment version in pom.xml
|
||||
@@ -71,7 +71,8 @@ jobs:
|
||||
working-directory: ./server
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
|
||||
- name: Tag and push Docker image
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Server API CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# run for all pushes, not only main
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'server/**/*.java'
|
||||
- 'server/**/*.xml'
|
||||
- 'server/pom.xml'
|
||||
|
||||
jobs:
|
||||
run-checks:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
|
||||
- name: Run Check Style
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
|
||||
|
||||
- name: Run build
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress clean compile -DskipTests
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
|
||||
@@ -1,101 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
### Frontend (React/TypeScript)
|
||||
```bash
|
||||
cd client
|
||||
npm start # Start development server on port 5000
|
||||
npm run build # Build production bundle (TypeScript compilation + Vite build)
|
||||
npm run preview # Preview production build
|
||||
npm test # Run Vitest tests
|
||||
npm run test:coverage # Run tests with coverage report
|
||||
npm run lint # Run ESLint
|
||||
npm run lint:fix # Run ESLint with auto-fix
|
||||
```
|
||||
|
||||
### Backend (Java/Spring Boot)
|
||||
```bash
|
||||
cd server
|
||||
./mvnw spring-boot:run # Start development server
|
||||
./mvnw clean compile # Compile Java sources
|
||||
./mvnw test # Run unit tests only
|
||||
./mvnw clean verify -Ptests # Run all tests (unit + integration) with coverage
|
||||
./mvnw spring-boot:build-image # Build Docker image
|
||||
./mvnw clean verify -Pnative # Build GraalVM native image
|
||||
```
|
||||
|
||||
### Docker Development Environment
|
||||
```bash
|
||||
bash tools/run-docker-db.sh # Start PostgreSQL database
|
||||
bash tools/run-docker-server.sh # Start backend in Docker
|
||||
bash tools/run-docker-client.sh # Start frontend in Docker
|
||||
bash tools/check-frontend.sh # Run frontend quality checks
|
||||
bash tools/check-backend.sh # Run backend quality checks
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
- `client/` - React TypeScript frontend (Vite + Vitest)
|
||||
- `server/` - Java Spring Boot REST API
|
||||
- `angular/` - Alternative Angular frontend (separate implementation)
|
||||
- `tools/` - Development and deployment scripts
|
||||
- `docker-compose.yml` - Multi-service development environment
|
||||
|
||||
### Frontend Architecture (client/)
|
||||
- **State Management**: React Context API for authentication and sidebar state
|
||||
- **Authentication**: JWT tokens stored in localStorage with automatic refresh (2-minute intervals)
|
||||
- **Routing**: Dynamic router configuration based on auth status (signed vs not-signed routes)
|
||||
- **Internationalization**: i18next with support for English, Portuguese, Russian, Spanish
|
||||
- **Styling**: Bootstrap 5 + SCSS with dark/light theme support
|
||||
- **Testing**: Vitest with React Testing Library and coverage reporting
|
||||
- **API Layer**: Centralized API service in `src/api-service/api.ts` with automatic auth headers
|
||||
|
||||
### Backend Architecture (server/)
|
||||
- **Framework**: Spring Boot 3.5+ with Java 17
|
||||
- **Security**: Spring Security with JWT authentication
|
||||
- **Database**: PostgreSQL with JPA/Hibernate and Flyway migrations
|
||||
- **Testing**: Separate unit tests and integration tests with 75% coverage requirement
|
||||
- **Documentation**: OpenAPI/Swagger UI available at `/swagger-ui.html`
|
||||
- **Build Options**: Traditional JAR or GraalVM native image compilation
|
||||
|
||||
### Key Components
|
||||
|
||||
**Authentication Flow:**
|
||||
1. User credentials → `/auth/sign-in` endpoint
|
||||
2. Server responds with JWT token and user data
|
||||
3. Token stored in localStorage, added to all API requests via Authorization header
|
||||
4. Automatic token refresh every 2 minutes via `/rest/user-sessions/refresh`
|
||||
5. Protected routes wrap authenticated pages, redirect on auth failure
|
||||
|
||||
**Database Schema:**
|
||||
- Core entities: Users, Tasks, Notes, Task URLs, Notes URLs
|
||||
- User management with password reset functionality
|
||||
- Task completion tracking and user statistics
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Start database: `bash tools/run-docker-db.sh`
|
||||
2. Start backend: `bash tools/run-docker-server.sh` or `cd server && ./mvnw spring-boot:run`
|
||||
3. Start frontend: `bash tools/run-docker-client.sh` or `cd client && npm start`
|
||||
4. Access app at http://localhost:5000
|
||||
|
||||
Always run quality checks before submitting changes:
|
||||
- Frontend: `bash tools/check-frontend.sh`
|
||||
- Backend: `bash tools/check-backend.sh`
|
||||
|
||||
## Testing
|
||||
|
||||
### Frontend Testing
|
||||
- **Framework**: Vitest + React Testing Library
|
||||
- **Coverage**: Generated in `client/coverage/` directory
|
||||
- **Run**: `cd client && npm test` or `npm run test:coverage`
|
||||
|
||||
### Backend Testing
|
||||
- **Unit Tests**: Standard JUnit tests, run with `./mvnw test`
|
||||
- **Integration Tests**: Files ending in `*IntTest.java`, require database
|
||||
- **Coverage**: JaCoCo reports, 75% minimum requirement
|
||||
- **Full Test Suite**: `./mvnw clean verify -Ptests` (includes checkstyle, coverage)
|
||||
@@ -1,21 +1,10 @@
|
||||
# TaskNote
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||

|
||||
|
||||
### TaskNote API
|
||||
[](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_server)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_server)
|
||||
[](https://sonarcloud.io/summary/new_code?id=br.com.tasknoteapp%3Aserver)
|
||||
[](https://sonarcloud.io/summary/new_code?id=br.com.tasknoteapp%3Aserver)
|
||||
[](https://sonarcloud.io/summary/new_code?id=br.com.tasknoteapp%3Aserver)
|
||||
|
||||
### TaskNote WebApp
|
||||
[](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml)
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
@@ -49,17 +38,17 @@ The project was born from a month-long technical challenge and has since grown t
|
||||
- **Responsive Design**: Mobile-first approach with Bootstrap 5 and dark/light theme support
|
||||
- **Data Visualization**: Task completion charts and productivity analytics
|
||||
- **File Attachments**: URL attachments for tasks and notes
|
||||
- **Tagging System**: `#tag` support for better organization
|
||||
- **Mobile App**: Native mobile applications for iOS and Android with PWA plugin
|
||||
|
||||
### Upcoming Features
|
||||
- **Tagging System**: `#tag` support for better organization
|
||||
- **Advanced Filters**: Enhanced search with date ranges, priority levels, and status filters
|
||||
- **Collaboration**: Share tasks and notes with other users
|
||||
- **Mobile App**: Native mobile applications for iOS and Android
|
||||
- **Notifications**: Email and push notifications for due dates and reminders
|
||||
|
||||
## 🚀 Tech Stack
|
||||
|
||||
### Frontend (React TypeScript)
|
||||
### Frontend (React & TypeScript)
|
||||
- **Framework**: React 19 with TypeScript for type safety
|
||||
- **Build Tool**: Vite for fast development and optimized production builds
|
||||
- **Testing**: Vitest with React Testing Library and comprehensive coverage reporting
|
||||
@@ -69,8 +58,8 @@ The project was born from a month-long technical challenge and has since grown t
|
||||
- **Internationalization**: i18next with automatic language detection
|
||||
- **API Client**: Centralized API service with automatic authentication headers
|
||||
|
||||
### Backend (Java Spring Boot)
|
||||
- **Framework**: Spring Boot 3.5+ with Java 17
|
||||
### Backend (Java & Spring Boot)
|
||||
- **Framework**: Spring Boot 4.x.x with Java 25
|
||||
- **Security**: Spring Security with JWT authentication and refresh tokens
|
||||
- **Database**: PostgreSQL with JPA/Hibernate ORM
|
||||
- **Migration**: Flyway for database schema versioning
|
||||
@@ -84,19 +73,16 @@ The project was born from a month-long technical challenge and has since grown t
|
||||
- **Containerization**: Docker and Docker Compose for development environment
|
||||
- **Web Server**: Caddy for reverse proxy and SSL termination
|
||||
- **CI/CD**: GitHub Actions with automated testing and quality gates
|
||||
- **Code Analysis**: SonarCloud integration for security and maintainability
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
```
|
||||
react-typescript-todolist/
|
||||
tasknote/
|
||||
├── client/ # React TypeScript frontend
|
||||
├── server/ # Java Spring Boot REST API
|
||||
├── angular/ # Alternative Angular frontend
|
||||
├── tools/ # Development and deployment scripts
|
||||
├── docker-compose.yml
|
||||
└── CLAUDE.md # AI assistant instructions
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
### Frontend Architecture
|
||||
@@ -117,15 +103,15 @@ react-typescript-todolist/
|
||||
|
||||
### Prerequisites
|
||||
- **Docker & Docker Compose** (recommended for easy setup)
|
||||
- **Node.js 18+** and **npm** (for frontend development)
|
||||
- **Java 17+** and **Maven 3.6+** (for backend development)
|
||||
- **PostgreSQL 13+** (if running without Docker)
|
||||
- **Node.js 20+** and **npm** (for frontend development)
|
||||
- **Java 25+** and **Maven 3.6+** (for backend development)
|
||||
- **PostgreSQL 15+** (if running without Docker)
|
||||
|
||||
### Quick Start with Docker
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/ricardo-campos-org/react-typescript-todolist.git
|
||||
cd react-typescript-todolist
|
||||
git clone https://github.com/rmcampos/tasknote.git
|
||||
cd tasknote
|
||||
```
|
||||
|
||||
2. **Start the database**
|
||||
@@ -145,7 +131,6 @@ react-typescript-todolist/
|
||||
|
||||
5. **Access the application**
|
||||
- Frontend: http://localhost:5000
|
||||
- API Documentation: http://localhost:8080/swagger-ui.html
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
@@ -234,7 +219,7 @@ We welcome contributions from the community! This project follows the **Fork & M
|
||||
1. **Fork the Project** on GitHub
|
||||
2. **Clone your fork** locally
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/react-typescript-todolist.git
|
||||
git clone https://github.com/YOUR_USERNAME/tasknote.git
|
||||
```
|
||||
3. **Create a feature branch**
|
||||
```bash
|
||||
@@ -269,9 +254,9 @@ For detailed setup instructions and development workflows, see [CONTRIBUTING.md]
|
||||
|
||||
**Ricardo Campos** - Full-Stack Developer & Project Maintainer
|
||||
|
||||
- **GitHub**: [@ricardo-campos-org](https://github.com/ricardo-campos-org)
|
||||
- **GitHub**: [@RMCampos](https://github.com/RMCampos)
|
||||
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs)
|
||||
- **LinkedIn**: [Ricardo Campos](https://www.linkedin.com/in/ricardo-campos-org/)
|
||||
- **LinkedIn**: [Ricardo Campos](https://www.linkedin.com/in/ricardompcampos/)
|
||||
|
||||
### About the Developer
|
||||
Ricardo is a passionate full-stack developer with expertise in modern web technologies, cloud architecture, and agile development practices. This project showcases his skills in:
|
||||
@@ -290,8 +275,8 @@ For questions, suggestions, or collaboration opportunities:
|
||||
|
||||
- **Email**: Contact via GitHub issues or discussions
|
||||
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs) for quick questions
|
||||
- **GitHub Issues**: [Create an issue](https://github.com/ricardo-campos-org/react-typescript-todolist/issues) for bugs or feature requests
|
||||
- **GitHub Discussions**: [Join discussions](https://github.com/ricardo-campos-org/react-typescript-todolist/discussions) for general questions
|
||||
- **GitHub Issues**: [Create an issue](https://github.com/rmcampos/tasknote/issues) for bugs or feature requests
|
||||
- **GitHub Discussions**: [Join discussions](https://github.com/rmcampos/tasknote/discussions) for general questions
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ tasks:
|
||||
|
||||
docker-build-api:
|
||||
desc: Build the tasknote-api prod-ready docker image, tagging it as candidate
|
||||
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/rmcampos/tasknote/api:latest
|
||||
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/rmcampos/tasknote/api:latest -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
|
||||
prod-up-web:
|
||||
desc: Speed up the tasknote-web prod-like image, building it if required
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ WORKDIR /app
|
||||
COPY *.html *.json *.ts ./
|
||||
COPY ./src ./src
|
||||
COPY ./public ./public
|
||||
RUN npm ci --ignore-scripts --no-update-notifier --omit=dev && \
|
||||
RUN npm i --ignore-scripts --no-update-notifier --omit=dev && \
|
||||
npm run build && \
|
||||
rm -rf node_modules
|
||||
|
||||
|
||||
Generated
+820
-502
File diff suppressed because it is too large
Load Diff
+23
-21
@@ -13,22 +13,23 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/node": "^25.0.8",
|
||||
"@types/node": "^25.3.2",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"bootstrap": "^5.3.8",
|
||||
"dompurify": "^3.3.1",
|
||||
"i18next": "^25.7.4",
|
||||
"react": "^19.2.3",
|
||||
"i18next": "^25.8.13",
|
||||
"react": "^19.2.4",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-bootstrap-icons": "^1.11.6",
|
||||
"react-charts": "^3.0.0-beta.57",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.12.0",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router-bootstrap": "^0.26.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
@@ -37,6 +38,7 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite build && vite preview",
|
||||
"test": "vitest",
|
||||
"test:no-watch": "vitest --watch=false",
|
||||
"test:coverage": "vitest run --coverage --watch=false",
|
||||
"lint": "./node_modules/.bin/eslint",
|
||||
"lint:fix": "./node_modules/.bin/eslint --fix"
|
||||
@@ -60,30 +62,30 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.1",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@stylistic/eslint-plugin": "^5.7.0",
|
||||
"@stylistic/eslint-plugin": "^5.9.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-router-bootstrap": "^0.26.8",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsdoc": "^62.0.0",
|
||||
"eslint-plugin-n": "^17.23.2",
|
||||
"eslint-plugin-jsdoc": "^62.7.1",
|
||||
"eslint-plugin-n": "^17.24.0",
|
||||
"eslint-plugin-promise": "^7.2.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.7.4",
|
||||
"sass": "^1.97.2",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"sass": "^1.97.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"vitest": "^4.0.17"
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ else
|
||||
fi
|
||||
|
||||
echo "3/3 - Tests started..."
|
||||
npm run test
|
||||
npm run test:no-watch
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running test. Please review.."
|
||||
exit 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { describe, vi, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, vi, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import ModalMarkdown from '../../components/ModalMarkdown';
|
||||
|
||||
describe('ModalMarkdown Component', () => {
|
||||
@@ -11,6 +11,14 @@ describe('ModalMarkdown Component', () => {
|
||||
onHide: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the modal with the correct title and markdown text', () => {
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
@@ -36,4 +44,64 @@ describe('ModalMarkdown Component', () => {
|
||||
|
||||
expect(screen.getByTestId('modal-header-title').innerHTML).toBe('No title');
|
||||
});
|
||||
|
||||
it('should show Source and Copy buttons', () => {
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
expect(screen.getByTestId('modal-source-button')).toBeDefined();
|
||||
expect(screen.getByTestId('modal-copy-button')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should toggle to source view when Source button is clicked', () => {
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
expect(screen.queryByTestId('markdown-source-view')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-source-button'));
|
||||
|
||||
expect(screen.getByTestId('markdown-source-view')).toBeDefined();
|
||||
expect(screen.getByTestId('markdown-source-view').textContent).toBe('# Test Markdown');
|
||||
});
|
||||
|
||||
it('should toggle back to rendered view when Source button is clicked again', () => {
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-source-button'));
|
||||
expect(screen.getByTestId('markdown-source-view')).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-source-button'));
|
||||
expect(screen.queryByTestId('markdown-source-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('should call clipboard writeText with markdownText when Copy button is clicked', async () => {
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('modal-copy-button'));
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('# Test Markdown');
|
||||
});
|
||||
|
||||
it('should show "Copied!" text after Copy button is clicked', async () => {
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('modal-copy-button'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset source view state when modal is closed', () => {
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-source-button'));
|
||||
expect(screen.getByTestId('markdown-source-view')).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByText('Close'));
|
||||
expect(props.onHide).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { test, describe, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, act, fireEvent, waitFor } from '@testing-library/react';
|
||||
import AuthContext from '../../context/AuthContext';
|
||||
import FilterProvider from '../../context/FilterProvider';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import api from '../../api-service/api';
|
||||
import { TaskResponse } from '../../types/TaskResponse';
|
||||
@@ -140,9 +141,20 @@ const authContextValue = {
|
||||
};
|
||||
|
||||
describe('Home Component', () => {
|
||||
const renderHome = () => render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<FilterProvider>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</FilterProvider>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks and setup default responses
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
(api.getJSON as any).mockImplementation((url: string) => {
|
||||
if (url.includes('tags')) {
|
||||
return Promise.resolve(mockTags);
|
||||
@@ -169,13 +181,7 @@ describe('Home Component', () => {
|
||||
|
||||
test('renders with initial data', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('content-header')).toBeDefined();
|
||||
@@ -193,13 +199,7 @@ describe('Home Component', () => {
|
||||
|
||||
test('filters tasks and notes when search text is entered', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -224,13 +224,7 @@ describe('Home Component', () => {
|
||||
|
||||
test('filters by radio button selection', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -273,13 +267,7 @@ describe('Home Component', () => {
|
||||
|
||||
test('filters by tag selection', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -314,13 +302,7 @@ describe('Home Component', () => {
|
||||
|
||||
test('marks task as done', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -355,13 +337,7 @@ describe('Home Component', () => {
|
||||
|
||||
test('deletes note', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -397,13 +373,7 @@ describe('Home Component', () => {
|
||||
|
||||
test('opens markdown modal when clicking on note tag', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -431,29 +401,150 @@ describe('Home Component', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByTestId('alert-error')).toBeDefined();
|
||||
expect(screen.getByTestId('alert-error').textContent).toBe('API Error');
|
||||
});
|
||||
|
||||
test('keeps filter selection after marking task as done', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('task-title').length).toBe(2);
|
||||
});
|
||||
|
||||
// Select "Only Tasks" filter
|
||||
const dropdownToggle = screen.getByTestId('main-label-selector');
|
||||
await act(async () => {
|
||||
fireEvent.click(dropdownToggle);
|
||||
});
|
||||
|
||||
const onlyTasksOption = screen.getByRole('button', { name: /home_radio_tasks/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(onlyTasksOption);
|
||||
});
|
||||
|
||||
// Verify filter is applied - notes should be hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('note-title').length).toBe(0);
|
||||
});
|
||||
|
||||
// Mark a task as done
|
||||
const dropdownToggles = screen.getAllByTestId('three-dots-icon');
|
||||
await act(async () => {
|
||||
fireEvent.click(dropdownToggles[0]);
|
||||
});
|
||||
|
||||
const dropdownItems = screen.getAllByRole('button');
|
||||
const markAsDoneButton = dropdownItems.find(
|
||||
item => item.textContent === 'task_table_action_done'
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(markAsDoneButton!);
|
||||
});
|
||||
|
||||
// After reload, filter should still be applied - notes should remain hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('note-title').length).toBe(0);
|
||||
expect(screen.getAllByTestId('task-title').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps filter selection after deleting a note', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('note-title').length).toBe(2);
|
||||
});
|
||||
|
||||
// Select "Only Notes" filter
|
||||
const dropdownToggle = screen.getByTestId('main-label-selector');
|
||||
await act(async () => {
|
||||
fireEvent.click(dropdownToggle);
|
||||
});
|
||||
|
||||
const onlyNotesOption = screen.getByRole('button', { name: /home_radio_notes/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(onlyNotesOption);
|
||||
});
|
||||
|
||||
// Verify filter is applied - tasks should be hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('task-title').length).toBe(0);
|
||||
});
|
||||
|
||||
// Delete a note
|
||||
const noteDropdownToggles = screen.getAllByTestId('three-dots-icon');
|
||||
await act(async () => {
|
||||
fireEvent.click(noteDropdownToggles[0]);
|
||||
});
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button');
|
||||
const deleteButton = deleteButtons.find(
|
||||
button => button.textContent === 'task_table_action_delete'
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton!);
|
||||
});
|
||||
|
||||
// After reload, filter should still be applied - tasks should remain hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('task-title').length).toBe(0);
|
||||
expect(screen.getAllByTestId('note-title').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps text search after marking task as done', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('task-title').length).toBe(2);
|
||||
});
|
||||
|
||||
// Enter search text to filter to only 'Task 1'
|
||||
const searchInput = screen.getByPlaceholderText('home_input_filter');
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'Task 1' } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('task-title').length).toBe(1);
|
||||
expect(screen.getAllByTestId('task-title')[0].textContent).toBe('Task 1');
|
||||
});
|
||||
|
||||
// Mark the task as done
|
||||
const dropdownToggles = screen.getAllByTestId('three-dots-icon');
|
||||
await act(async () => {
|
||||
fireEvent.click(dropdownToggles[0]);
|
||||
});
|
||||
|
||||
const dropdownItems = screen.getAllByRole('button');
|
||||
const markAsDoneButton = dropdownItems.find(
|
||||
item => item.textContent === 'task_table_action_done'
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(markAsDoneButton!);
|
||||
});
|
||||
|
||||
// After reload, text filter should still be applied
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('task-title').length).toBe(1);
|
||||
expect(screen.getAllByTestId('task-title')[0].textContent).toBe('Task 1');
|
||||
});
|
||||
});
|
||||
/*
|
||||
test('getFirstRows properly formats note preview', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import Modal from 'react-bootstrap/Modal';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import './style.css';
|
||||
|
||||
type Props = {
|
||||
@@ -22,11 +23,31 @@ type Props = {
|
||||
* @returns {React.ReactNode} the Markdown component rendered.
|
||||
*/
|
||||
const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
|
||||
const [showSource, setShowSource] = useState<boolean>(false);
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
const handleToggleSource = () => setShowSource(prev => !prev);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(props.markdownText).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}).catch(() => {
|
||||
// clipboard write failed silently; no state change
|
||||
});
|
||||
};
|
||||
|
||||
const handleHide = () => {
|
||||
setShowSource(false);
|
||||
setCopied(false);
|
||||
props.onHide();
|
||||
};
|
||||
|
||||
return props.show
|
||||
? (
|
||||
<Modal
|
||||
show={props.show}
|
||||
onHide={props.onHide}
|
||||
onHide={handleHide}
|
||||
backdrop="static"
|
||||
keyboard={false}
|
||||
size="xl"
|
||||
@@ -41,12 +62,34 @@ const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="markdown-modal">
|
||||
<Markdown>{props.markdownText}</Markdown>
|
||||
{showSource
|
||||
? (
|
||||
<pre className="markdown-source" data-testid="markdown-source-view">
|
||||
{props.markdownText}
|
||||
</pre>
|
||||
)
|
||||
: (
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{props.markdownText}</Markdown>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-secondary" onClick={props.onHide}>
|
||||
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
|
||||
<Button variant="outline-secondary" onClick={handleHide}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant={showSource ? 'info' : 'outline-info'}
|
||||
onClick={handleToggleSource}
|
||||
data-testid="modal-source-button"
|
||||
>
|
||||
Source
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={handleCopy}
|
||||
data-testid="modal-copy-button"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,177 @@
|
||||
.markdown-modal {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: 1.25em;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.85em;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--bs-link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
ul ul,
|
||||
ul ol,
|
||||
ol ul,
|
||||
ol ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Task list checkboxes */
|
||||
li input[type='checkbox'] {
|
||||
margin-right: 0.4em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
li.task-list-item {
|
||||
list-style: none;
|
||||
margin-left: -1.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1em;
|
||||
padding: 0 1em;
|
||||
color: var(--bs-secondary-color);
|
||||
border-left: 4px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.2em 0.4em;
|
||||
font-size: 85%;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
padding: 1em;
|
||||
overflow: auto;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin-bottom: 1em;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
table th {
|
||||
font-weight: 600;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
margin: 1.5em 0;
|
||||
border: none;
|
||||
background-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
del {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-source {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
padding: 1em;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.markdown-source {
|
||||
font-size: 12px;
|
||||
padding: 0.75em;
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export interface FilterContextData {
|
||||
filterText: string;
|
||||
selectedOption: string;
|
||||
setFilterText: (text: string) => void;
|
||||
setSelectedOption: (option: string) => void;
|
||||
}
|
||||
|
||||
const FilterContext = createContext<FilterContextData>({} as FilterContextData);
|
||||
|
||||
export default FilterContext;
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import FilterContext, { FilterContextData } from './FilterContext';
|
||||
|
||||
const FILTER_TEXT_KEY = 'FILTER_TEXT';
|
||||
const FILTER_OPTION_KEY = 'FILTER_OPTION';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const FilterProvider: React.FC<Props> = ({ children }: Props) => {
|
||||
const [filterText, setFilterTextState] = useState<string>(
|
||||
() => localStorage.getItem(FILTER_TEXT_KEY) ?? ''
|
||||
);
|
||||
const [selectedOption, setSelectedOptionState] = useState<string>(
|
||||
() => localStorage.getItem(FILTER_OPTION_KEY) ?? 'everything'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FILTER_TEXT_KEY, filterText);
|
||||
}, [filterText]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FILTER_OPTION_KEY, selectedOption);
|
||||
}, [selectedOption]);
|
||||
|
||||
const setFilterText = (text: string): void => {
|
||||
setFilterTextState(text);
|
||||
};
|
||||
|
||||
const setSelectedOption = (option: string): void => {
|
||||
setSelectedOptionState(option);
|
||||
};
|
||||
|
||||
const contextValue: FilterContextData = useMemo(() => ({
|
||||
filterText,
|
||||
selectedOption,
|
||||
setFilterText,
|
||||
setSelectedOption
|
||||
}), [filterText, selectedOption]);
|
||||
|
||||
return (
|
||||
<FilterContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FilterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterProvider;
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import AuthProvider from './context/AuthProvider';
|
||||
import FilterProvider from './context/FilterProvider';
|
||||
import SidebarProvider from './context/SidebarProvider';
|
||||
import './i18n';
|
||||
|
||||
@@ -14,9 +15,11 @@ const root = createRoot(
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<SidebarProvider>
|
||||
<App />
|
||||
</SidebarProvider>
|
||||
<FilterProvider>
|
||||
<SidebarProvider>
|
||||
<App />
|
||||
</SidebarProvider>
|
||||
</FilterProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import ApiConfig from '../../api-service/apiConfig';
|
||||
import { handleDefaultLang } from '../../lang-service/LangHandler';
|
||||
import { translateServerResponse, translateTaskResponse } from '../../utils/TranslatorUtils';
|
||||
import AuthContext from '../../context/AuthContext';
|
||||
import FilterContext from '../../context/FilterContext';
|
||||
import ContentHeader from '../../components/ContentHeader';
|
||||
import AlertError from '../../components/AlertError';
|
||||
import { ThreeDotsVertical } from 'react-bootstrap-icons';
|
||||
@@ -36,6 +37,7 @@ import NoteTitle from '../../components/NoteTitle';
|
||||
*/
|
||||
function Home(): React.ReactNode {
|
||||
const { user } = useContext(AuthContext);
|
||||
const { filterText, selectedOption, setFilterText, setSelectedOption } = useContext(FilterContext);
|
||||
const { i18n, t } = useTranslation();
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
@@ -43,12 +45,10 @@ function Home(): React.ReactNode {
|
||||
const [showMarkdownView, setShowMarkdownView] = useState<boolean>(false);
|
||||
const [modalTitle, setModalTitle] = useState<string>('');
|
||||
const [modalContent, setModalContent] = useState<string>('');
|
||||
const [filterText, setFilterText] = useState<string>('');
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>([]);
|
||||
const [notes, setNotes] = useState<NoteResponse[]>([]);
|
||||
const [savedNotes, setSavedNotes] = useState<NoteResponse[]>([]);
|
||||
const [savedTasks, setSavedTasks] = useState<TaskResponse[]>([]);
|
||||
const [selectedOption, setSelectedOption] = useState<string>('everything');
|
||||
|
||||
/**
|
||||
* Handles the error by setting the error message.
|
||||
@@ -105,14 +105,17 @@ function Home(): React.ReactNode {
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter notes by a given text.
|
||||
* Apply filters to a given set of tasks and notes, updating displayed state.
|
||||
*
|
||||
* @param {string} text - The text to filter by.
|
||||
* @param {string | undefined} radioFilter - The radio filter option.
|
||||
* @param {TaskResponse[]} allTasks - The full list of tasks to filter from.
|
||||
* @param {NoteResponse[]} allNotes - The full list of notes to filter from.
|
||||
*/
|
||||
const filterTasksAndNotes = (text: string, radioFilter?: string): void => {
|
||||
setFilterText(text);
|
||||
|
||||
if (!text && !radioFilter) {
|
||||
setNotes([...savedNotes]);
|
||||
setTasks([...savedTasks]);
|
||||
const applyFilter = (text: string, radioFilter: string | undefined, allTasks: TaskResponse[], allNotes: NoteResponse[]): void => {
|
||||
if (!text && (!radioFilter || radioFilter === 'everything')) {
|
||||
setNotes([...allNotes]);
|
||||
setTasks([...allTasks]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,11 +125,12 @@ function Home(): React.ReactNode {
|
||||
setNotes([]);
|
||||
}
|
||||
else {
|
||||
let filteredNotes = savedNotes.filter((note: NoteResponse) => {
|
||||
let filteredNotes = allNotes.filter((note: NoteResponse) => {
|
||||
const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase());
|
||||
const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase());
|
||||
const anyUrlMatch = note.url?.includes(text.toLowerCase());
|
||||
return anyTitleMatch || anyContentMatch || anyUrlMatch;
|
||||
const anyTagMatch = note.tag?.toLowerCase().includes(text.toLowerCase());
|
||||
return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch;
|
||||
});
|
||||
|
||||
if (tagToFilter === 'untagged') {
|
||||
@@ -143,7 +147,7 @@ function Home(): React.ReactNode {
|
||||
setTasks([]);
|
||||
}
|
||||
else {
|
||||
let filteredTasks = savedTasks.filter((task: TaskResponse) => {
|
||||
let filteredTasks = allTasks.filter((task: TaskResponse) => {
|
||||
const shouldFilter = task.description.toLowerCase().includes(text.toLowerCase())
|
||||
|| task.tag.toLowerCase().includes(text.toLowerCase())
|
||||
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
|
||||
@@ -161,6 +165,14 @@ function Home(): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter notes by a given text.
|
||||
*/
|
||||
const filterTasksAndNotes = (text: string, radioFilter?: string): void => {
|
||||
setFilterText(text);
|
||||
applyFilter(text, radioFilter, savedTasks, savedNotes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load tasks from the server.
|
||||
*/
|
||||
@@ -178,8 +190,6 @@ function Home(): React.ReactNode {
|
||||
return 1;
|
||||
});
|
||||
setSavedTasks([...translated]);
|
||||
setTasks([...translated]);
|
||||
setSelectedOption('everything');
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -193,7 +203,6 @@ function Home(): React.ReactNode {
|
||||
try {
|
||||
const notesFetched: NoteResponse[] = await api.getJSON(ApiConfig.notesUrl);
|
||||
notesFetched.sort((n1, n2) => (n1.id > n2.id) ? -1 : 1);
|
||||
setNotes([...notesFetched]);
|
||||
setSavedNotes([...notesFetched]);
|
||||
}
|
||||
catch (e) {
|
||||
@@ -305,6 +314,10 @@ function Home(): React.ReactNode {
|
||||
loadAllNotes();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilter(filterText, selectedOption, savedTasks, savedNotes);
|
||||
}, [savedTasks, savedNotes, filterText, selectedOption]);
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<ContentHeader
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Form,
|
||||
InputGroup,
|
||||
ListGroup,
|
||||
Row
|
||||
} from 'react-bootstrap';
|
||||
import { Hash } from 'react-bootstrap-icons';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NoteResponse } from '../../types/NoteResponse';
|
||||
@@ -32,11 +35,24 @@ function NoteAdd(): React.ReactNode {
|
||||
const [noteContent, setNoteContent] = useState<string>('');
|
||||
const [noteUrl, setNoteUrl] = useState<string>('');
|
||||
const [noteTag, setNoteTag] = useState<string>('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const [action, setAction] = useState<NoteAction>('add');
|
||||
const [showPreviewMd, setShowPreviewMd] = useState<boolean>(false);
|
||||
const { i18n, t } = useTranslation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const tagContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadTags = async (): Promise<void> => {
|
||||
try {
|
||||
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
|
||||
setTags(response);
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles errors by setting the error message and form invalid state.
|
||||
@@ -222,8 +238,20 @@ function NoteAdd(): React.ReactNode {
|
||||
const handleCloseModal = (): void => setShowPreviewMd(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
checkEditUrl();
|
||||
checkCloneUrl();
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
|
||||
setShowTagDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -284,19 +312,57 @@ function NoteAdd(): React.ReactNode {
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} xl={3}>
|
||||
{/* Tag */}
|
||||
<FormInput
|
||||
labelText="Tag"
|
||||
iconName="Hash"
|
||||
required={false}
|
||||
type="text"
|
||||
name="tag"
|
||||
placeholder="my-tag (Optional)"
|
||||
value={noteTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteTag(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{/* Tag with suggestion dropdown */}
|
||||
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
|
||||
<Form.Label>Tag</Form.Label>
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup.Text>
|
||||
<Hash />
|
||||
</InputGroup.Text>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name="tag"
|
||||
placeholder="my-tag (Optional)"
|
||||
value={noteTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteTag(e.target.value);
|
||||
setShowTagDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowTagDropdown(true)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(noteTag.toLowerCase())).length > 0 && (
|
||||
<ListGroup
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 1000,
|
||||
width: '100%',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
top: '100%',
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
{tags
|
||||
.filter(t => t.toLowerCase().includes(noteTag.toLowerCase()))
|
||||
.map(t => (
|
||||
<ListGroup.Item
|
||||
key={t}
|
||||
action
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setNoteTag(t);
|
||||
setShowTagDropdown(false);
|
||||
}}
|
||||
>
|
||||
#
|
||||
{t}
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
)}
|
||||
</Form.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Form,
|
||||
InputGroup,
|
||||
ListGroup,
|
||||
Row
|
||||
} from 'react-bootstrap';
|
||||
import { Hash } from 'react-bootstrap-icons';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import TaskNoteRequest from '../../types/TaskNoteRequest';
|
||||
import { TaskResponse } from '../../types/TaskResponse';
|
||||
@@ -35,9 +38,22 @@ function TaskAdd(): React.ReactNode {
|
||||
const [dueDate, setDueDate] = useState<Date | null>(null);
|
||||
const [highPriority, setHighPriority] = useState<boolean>(false);
|
||||
const [tag, setTag] = useState<string>('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const { i18n, t } = useTranslation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const tagContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadTags = async (): Promise<void> => {
|
||||
try {
|
||||
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
|
||||
setTags(response);
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles errors by setting the error message and form invalid state.
|
||||
@@ -190,7 +206,19 @@ function TaskAdd(): React.ReactNode {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
checkEditUrl();
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
|
||||
setShowTagDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -267,19 +295,57 @@ function TaskAdd(): React.ReactNode {
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} xxl={3}>
|
||||
{/* Tag */}
|
||||
<FormInput
|
||||
labelText="Tag"
|
||||
iconName="Hash"
|
||||
required={false}
|
||||
type="text"
|
||||
name="tag"
|
||||
placeholder="my-tag (Optional)"
|
||||
value={tag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTag(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{/* Tag with suggestion dropdown */}
|
||||
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
|
||||
<Form.Label>Tag</Form.Label>
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup.Text>
|
||||
<Hash />
|
||||
</InputGroup.Text>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name="tag"
|
||||
placeholder="my-tag (Optional)"
|
||||
value={tag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTag(e.target.value);
|
||||
setShowTagDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowTagDropdown(true)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(tag.toLowerCase())).length > 0 && (
|
||||
<ListGroup
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 1000,
|
||||
width: '100%',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
top: '100%',
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
{tags
|
||||
.filter(t => t.toLowerCase().includes(tag.toLowerCase()))
|
||||
.map(t => (
|
||||
<ListGroup.Item
|
||||
key={t}
|
||||
action
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setTag(t);
|
||||
setShowTagDropdown(false);
|
||||
}}
|
||||
>
|
||||
#
|
||||
{t}
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
)}
|
||||
</Form.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
@@ -39,13 +39,13 @@ services:
|
||||
ports:
|
||||
- "8585:8585"
|
||||
- "5005:5005"
|
||||
image: maven:3.9.9-eclipse-temurin-21
|
||||
image: maven:3.9.9-eclipse-temurin-25
|
||||
entrypoint: './mvnw -ntp spring-boot:run -Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" -Dmaven.plugin.validation=VERBOSE'
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- "./server:/app"
|
||||
healthcheck:
|
||||
test: curl -f http://localhost:8585/actuator/health | grep '"status":"UP"'
|
||||
test: curl -f http://localhost:8585/health | grep '"status":"UP"'
|
||||
interval: 1m30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
|
||||
+11
-8
@@ -6,13 +6,13 @@ services:
|
||||
depends_on:
|
||||
tasknote-api:
|
||||
condition: service_healthy
|
||||
image: docker.io/rmcampos/tasknote:app-latest
|
||||
image: ghcr.io/rmcampos/tasknote/app:latest
|
||||
build:
|
||||
context: client
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
ports: ["5000:5000"]
|
||||
environment:
|
||||
VITE_BACKEND_SERVER: http://tasknote-api:8585
|
||||
VITE_BACKEND_SERVER: http://localhost:8585
|
||||
networks:
|
||||
- tasknote-network
|
||||
|
||||
@@ -33,16 +33,19 @@ services:
|
||||
SECURITY_KEY: this-is-a-very-long-security-key-for-dev
|
||||
MAILGUN_APIKEY: invalid-api-key-only-placeholder
|
||||
ports: ["8585:8585"]
|
||||
image: docker.io/rmcampos/tasknote:api-latest
|
||||
build:
|
||||
context: server
|
||||
dockerfile: Dockerfile
|
||||
image: ghcr.io/rmcampos/tasknote/api:latest
|
||||
healthcheck:
|
||||
test: ["CMD", "/layers/local_healthcheck/healthcheck/bin/healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- tasknote-network
|
||||
|
||||
tasknote-db:
|
||||
container_name: tasknote-db
|
||||
image: postgres:15.8-bookworm
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_USER: tasknoteuser
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build check.go
|
||||
chmod +x check
|
||||
mv check buildpacks/healthcheck/healthcheck
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
LAYERS_DIR="$1"
|
||||
|
||||
echo "CNB_BUILDPACK_DIR: ${CNB_BUILDPACK_DIR}"
|
||||
echo "Contents of buildpack dir:"
|
||||
ls -la "${CNB_BUILDPACK_DIR}"
|
||||
|
||||
BINARY_SOURCE="${CNB_BUILDPACK_DIR}/healthcheck"
|
||||
|
||||
if [ ! -f "${BINARY_SOURCE}" ]; then
|
||||
echo "ERROR: binary not found at ${BINARY_SOURCE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LAYER_DIR="${LAYERS_DIR}/healthcheck"
|
||||
mkdir -p "${LAYER_DIR}/bin"
|
||||
|
||||
cp "${BINARY_SOURCE}" "${LAYER_DIR}/bin/healthcheck"
|
||||
chmod +x "${LAYER_DIR}/bin/healthcheck"
|
||||
|
||||
cat > "${LAYER_DIR}.toml" <<EOL
|
||||
[types]
|
||||
launch = true
|
||||
build = false
|
||||
cache = false
|
||||
EOL
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
@@ -0,0 +1,11 @@
|
||||
api = "0.7"
|
||||
|
||||
[buildpack]
|
||||
id = "local/healthcheck"
|
||||
version = "0.1.0"
|
||||
name = "Healthcheck Binary Buildpack"
|
||||
[[stacks]]
|
||||
id = "io.buildpacks.stacks.jammy"
|
||||
|
||||
[[stacks]]
|
||||
id = "*"
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
// Use 127.0.0.1 as the healthcheck runs inside the container
|
||||
resp, err := client.Get("http://127.0.0.1:8585/health")
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
+26
-9
@@ -5,13 +5,13 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.9</version>
|
||||
<version>4.0.3</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<groupId>br.com.tasknoteapp</groupId>
|
||||
<artifactId>server</artifactId>
|
||||
<version>3</version>
|
||||
<version>9</version>
|
||||
<name>tasknote-api</name>
|
||||
<description>Java backend REST API to serve TaskNote frontend client</description>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<!-- Properties -->
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<skip.integration.tests>true</skip.integration.tests>
|
||||
@@ -80,7 +80,11 @@
|
||||
<!-- Spring -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -105,6 +109,10 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-flyway</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
@@ -117,12 +125,12 @@
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<artifactId>spring-boot-starter-test-classic</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -153,8 +161,8 @@
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.hibernate.orm.tooling</groupId>
|
||||
<artifactId>hibernate-enhance-maven-plugin</artifactId>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-maven-plugin</artifactId>
|
||||
<version>${hibernate.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
@@ -351,6 +359,15 @@
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<image>
|
||||
<name>ghcr.io/rmcampos/tasknote/api:latest</name>
|
||||
<buildpacks>
|
||||
<buildpack>file://${project.basedir}/buildpacks/healthcheck</buildpack>
|
||||
<buildpack>urn:cnb:builder:paketo-buildpacks/java-native-image</buildpack>
|
||||
</buildpacks>
|
||||
</image>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[build]
|
||||
builder = "paketobuildpacks/builder-jammy-tiny:latest"
|
||||
|
||||
[[build.buildpacks]]
|
||||
uri = "buildpacks/healthcheck"
|
||||
|
||||
[[build.buildpacks]]
|
||||
uri = "urn:cnb:builder:paketo-buildpacks/java-native-image"
|
||||
@@ -1,23 +1,11 @@
|
||||
package br.com.tasknoteapp.server;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
import br.com.tasknoteapp.server.service.AppVersionService;
|
||||
|
||||
/** Entrypoint of the Java API service application. */
|
||||
@SpringBootApplication
|
||||
public class JavaApiApplication implements ApplicationRunner {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JavaApiApplication.class);
|
||||
|
||||
@Autowired
|
||||
private AppVersionService appVersionService;
|
||||
public class JavaApiApplication {
|
||||
|
||||
/**
|
||||
* Main method of the application.
|
||||
@@ -27,9 +15,4 @@ public class JavaApiApplication implements ApplicationRunner {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(JavaApiApplication.class, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws Exception {
|
||||
logger.info("Task Note API started successfully - Version: {}", appVersionService.getVersion());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@@ -23,8 +22,7 @@ public class CorsConfig implements WebMvcConfigurer {
|
||||
*
|
||||
* @param registry CorsRegistry instance.
|
||||
*/
|
||||
@SuppressWarnings("null")
|
||||
public void addCorsMappings(@NonNull CorsRegistry registry) {
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
if (allowedOrigins != null && allowedOrigins.length > 0) {
|
||||
logger.info("CORS policy allowed origins: {}", Arrays.asList(allowedOrigins));
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
package br.com.tasknoteapp.server.controller;
|
||||
|
||||
import br.com.tasknoteapp.server.service.AppVersionService;
|
||||
import java.sql.Connection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import br.com.tasknoteapp.server.service.AppVersionService;
|
||||
|
||||
/** Controller to handle health check requests. */
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
@@ -18,9 +18,9 @@ public class HomeController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the top 5 tags.
|
||||
* Get all existing tags, ordered alphabetically.
|
||||
*
|
||||
* @returns List of String with the tags.
|
||||
* @return List of String with the tags.
|
||||
*/
|
||||
@GetMapping("/tasks/tags")
|
||||
public List<String> getTasksTags() {
|
||||
|
||||
@@ -10,7 +10,6 @@ import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
@@ -49,7 +48,7 @@ public class NoteController {
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public NoteResponse getTaskById(@NonNull @PathVariable Long id) {
|
||||
public NoteResponse getTaskById(@PathVariable Long id) {
|
||||
return noteService.getNoteById(id);
|
||||
}
|
||||
|
||||
@@ -63,7 +62,7 @@ public class NoteController {
|
||||
*/
|
||||
@PatchMapping("/{id}")
|
||||
public ResponseEntity<NoteResponse> patchNote(
|
||||
@PathVariable @NonNull Long id, @RequestBody @Valid NotePatchRequest noteRequest) {
|
||||
@PathVariable Long id, @RequestBody @Valid NotePatchRequest noteRequest) {
|
||||
|
||||
return ResponseEntity.ok(noteService.patchNote(id, noteRequest));
|
||||
}
|
||||
@@ -88,7 +87,7 @@ public class NoteController {
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteNote(@NonNull @PathVariable Long id) {
|
||||
public ResponseEntity<Void> deleteNote(@PathVariable Long id) {
|
||||
noteService.deleteNote(id);
|
||||
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
@@ -48,7 +47,7 @@ public class TaskController {
|
||||
* @throws TaskNotFoundException when task not found.
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public TaskResponse getTaskById(@NonNull @PathVariable Long id) {
|
||||
public TaskResponse getTaskById(@PathVariable Long id) {
|
||||
return taskService.getTaskById(id);
|
||||
}
|
||||
|
||||
@@ -62,7 +61,7 @@ public class TaskController {
|
||||
*/
|
||||
@PatchMapping("/{id}")
|
||||
public ResponseEntity<TaskResponse> patchTask(
|
||||
@PathVariable @NonNull Long id, @RequestBody @Valid TaskPatchRequest taskRequest) {
|
||||
@PathVariable Long id, @RequestBody @Valid TaskPatchRequest taskRequest) {
|
||||
return ResponseEntity.ok(taskService.patchTask(id, taskRequest));
|
||||
}
|
||||
|
||||
@@ -86,7 +85,7 @@ public class TaskController {
|
||||
* @throws TaskNotFoundException when task not found.
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteTask(@NonNull @PathVariable Long id) {
|
||||
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
|
||||
taskService.deleteTask(id);
|
||||
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@@ -19,9 +18,9 @@ public class HeaderVersionFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain)
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
response.setHeader("X-BUILD-INFO", appVersionService.getVersion());
|
||||
filterChain.doFilter(request, response);
|
||||
|
||||
@@ -9,7 +9,6 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
@@ -28,9 +27,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain)
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
final String authorizationHeader = request.getHeader("Authorization");
|
||||
|
||||
|
||||
+1
-3
@@ -4,14 +4,12 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.aot.hint.ProxyHints;
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.RuntimeHintsRegistrar;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
/** This class creates a RuntimeHint for the ServletRequest class. */
|
||||
public class HttpServletRequestRuntimeHint implements RuntimeHintsRegistrar {
|
||||
|
||||
@Override
|
||||
public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
|
||||
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
|
||||
try {
|
||||
ProxyHints proxies = hints.proxies();
|
||||
proxies.registerJdkProxy(HttpServletRequest.class);
|
||||
|
||||
@@ -7,8 +7,6 @@ import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.RuntimeHintsRegistrar;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.ImportRuntimeHints;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
/**
|
||||
* This class creates RuntimeHints for JJWT exceptions to ensure they are available at runtime in
|
||||
@@ -20,7 +18,7 @@ public class JjwtRuntimeHints {
|
||||
|
||||
static class JjwtHintsRegistrar implements RuntimeHintsRegistrar {
|
||||
@Override
|
||||
public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
|
||||
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
|
||||
// Register JJWT exceptions for reflection
|
||||
hints
|
||||
.reflection()
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package br.com.tasknoteapp.server.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Service to retrieve application version information. */
|
||||
@Service
|
||||
public class AppVersionService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AppVersionService.class);
|
||||
|
||||
private final BuildProperties buildProperties;
|
||||
|
||||
/**
|
||||
@@ -14,16 +21,25 @@ public class AppVersionService {
|
||||
*
|
||||
* @param buildProperties the build properties injected by Spring Boot
|
||||
*/
|
||||
public AppVersionService(BuildProperties buildProperties) {
|
||||
public AppVersionService(@Autowired(required = false) BuildProperties buildProperties) {
|
||||
this.buildProperties = buildProperties;
|
||||
}
|
||||
|
||||
/** Logs the application version once the application context is fully started. */
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onApplicationReady() {
|
||||
logger.info("Task Note API started successfully - Version: {}", getVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the application version combined with the build time.
|
||||
*
|
||||
* @return a string representing the application version and build time
|
||||
*/
|
||||
public String getVersion() {
|
||||
if (buildProperties == null) {
|
||||
return "unknown";
|
||||
}
|
||||
return buildProperties.getVersion() + "-" + buildProperties.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package br.com.tasknoteapp.server.service;
|
||||
|
||||
import br.com.tasknoteapp.server.response.NoteResponse;
|
||||
import br.com.tasknoteapp.server.response.TaskResponse;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -18,44 +17,45 @@ public class HomeService {
|
||||
|
||||
private final TaskService taskService;
|
||||
|
||||
private static final String N_TASKS_FOUND = "%d tasks found!";
|
||||
private final NoteService noteService;
|
||||
|
||||
public HomeService(TaskService taskService) {
|
||||
public HomeService(TaskService taskService, NoteService noteService) {
|
||||
this.taskService = taskService;
|
||||
this.noteService = noteService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get up to 5 most used tags.
|
||||
* Get all existing tags, ordered alphabetically.
|
||||
*
|
||||
* @return List of String with the tags.
|
||||
*/
|
||||
public List<String> getTopTasksTag() {
|
||||
logger.info("Getting top tags for the tasks");
|
||||
logger.info("Getting all tags for tasks and notes");
|
||||
|
||||
List<TaskResponse> tasks = taskService.getTasksByFilter("all");
|
||||
logger.info(String.format(N_TASKS_FOUND, tasks.size()));
|
||||
List<NoteResponse> notes = noteService.getAllNotes();
|
||||
|
||||
Map<String, Integer> tagsCount = new HashMap<>();
|
||||
for (TaskResponse task : tasks) {
|
||||
if (tagsCount.size() == 5) {
|
||||
break;
|
||||
}
|
||||
Set<String> tags = new HashSet<>();
|
||||
tags.addAll(
|
||||
tasks.stream()
|
||||
.map(TaskResponse::tag)
|
||||
.filter(tag -> tag != null && !tag.isBlank())
|
||||
.toList());
|
||||
tags.addAll(
|
||||
notes.stream()
|
||||
.map(NoteResponse::tag)
|
||||
.filter(tag -> tag != null && !tag.isBlank())
|
||||
.toList());
|
||||
|
||||
String tag = task.tag();
|
||||
if (tag.isBlank()) {
|
||||
tag = "untagged";
|
||||
}
|
||||
tagsCount.putIfAbsent(tag, 0);
|
||||
tagsCount.put(tag, tagsCount.get(tag) + 1);
|
||||
boolean hasBlankTags =
|
||||
tasks.stream().anyMatch(task -> task.tag() == null || task.tag().isBlank())
|
||||
|| notes.stream().anyMatch(note -> note.tag() == null || note.tag().isBlank());
|
||||
if (hasBlankTags) {
|
||||
tags.add("untagged");
|
||||
}
|
||||
|
||||
Map<String, Integer> sortedDesc =
|
||||
tagsCount.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
Map.Entry::getKey, Map.Entry::getValue, (e1, e2) -> e1, LinkedHashMap::new));
|
||||
logger.info("Found {} tags", tags.size());
|
||||
|
||||
return sortedDesc.keySet().stream().toList();
|
||||
return tags.stream().sorted().toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.Objects;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
@@ -18,7 +18,6 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** This class implements the NoteService interface methods. */
|
||||
@@ -76,7 +75,7 @@ public class NoteService {
|
||||
* @param noteId The task id in the database.
|
||||
* @return {@link NoteResponse} with the found task or throw a {@link TaskNotFoundException}.
|
||||
*/
|
||||
public NoteResponse getNoteById(@NonNull Long noteId) {
|
||||
public NoteResponse getNoteById(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get note " + noteId + " to user " + user.getId());
|
||||
|
||||
@@ -127,7 +126,7 @@ public class NoteService {
|
||||
* @return {@link NoteResponse} containing the updated note.
|
||||
*/
|
||||
@Transactional
|
||||
public NoteResponse patchNote(@NonNull Long noteId, NotePatchRequest patch) {
|
||||
public NoteResponse patchNote(Long noteId, NotePatchRequest patch) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Patching task " + noteId + " to user " + user.getId());
|
||||
@@ -174,7 +173,7 @@ public class NoteService {
|
||||
* @param noteId The note id from the database.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteNote(@NonNull Long noteId) {
|
||||
public void deleteNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Deleting note " + noteId + " to user " + user.getId());
|
||||
|
||||
@@ -21,7 +21,6 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** This class contains the implementation for the Task Service class. */
|
||||
@@ -80,7 +79,7 @@ public class TaskService {
|
||||
* @param taskId The task id in the database.
|
||||
* @return {@link TaskResponse} with the found task or throw a {@link TaskNotFoundException}.
|
||||
*/
|
||||
public TaskResponse getTaskById(@NonNull Long taskId) {
|
||||
public TaskResponse getTaskById(Long taskId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get task " + taskId + " to user " + user.getId());
|
||||
|
||||
@@ -131,7 +130,7 @@ public class TaskService {
|
||||
* @return {@link TaskResponse} with the updated content.
|
||||
*/
|
||||
@Transactional
|
||||
public TaskResponse patchTask(@NonNull Long taskId, TaskPatchRequest patch) {
|
||||
public TaskResponse patchTask(Long taskId, TaskPatchRequest patch) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Patching task " + taskId + " to user " + user.getId());
|
||||
@@ -177,7 +176,7 @@ public class TaskService {
|
||||
* @param taskId The task id in the database
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteTask(@NonNull Long taskId) {
|
||||
public void deleteTask(Long taskId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Deleting task " + taskId + " to user " + user.getId());
|
||||
|
||||
+1
-1
@@ -15,8 +15,8 @@ import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@@ -11,8 +11,8 @@ import java.util.List;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
@@ -46,7 +46,7 @@ class HomeControllerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Get task tags not authorized it should fail")
|
||||
void getTasksTags_notAuthorized_shouldSucceed() throws Exception {
|
||||
void getTasksTags_notAuthorized_shouldFail() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/rest/home/tasks/tags")
|
||||
|
||||
@@ -23,8 +23,8 @@ import org.hamcrest.Matchers;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
@@ -21,8 +21,8 @@ import org.hamcrest.Matchers;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
@@ -14,8 +14,8 @@ import java.util.List;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
+1
-1
@@ -13,8 +13,8 @@ import br.com.tasknoteapp.server.service.UserSessionService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
+3
-3
@@ -11,9 +11,9 @@ import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
+3
-3
@@ -9,9 +9,9 @@ import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
@DataJpaTest
|
||||
|
||||
+3
-3
@@ -6,9 +6,9 @@ import java.util.UUID;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
@DataJpaTest
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.Sort.Direction;
|
||||
@@ -41,9 +42,8 @@ import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthServiceTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@@ -94,8 +94,6 @@ class AuthServiceTest {
|
||||
entity.setEmailUuid(UUID.randomUUID());
|
||||
|
||||
when(userRepository.save(any())).thenReturn(entity);
|
||||
when(jwtService.generateToken(any())).thenReturn("a1b2c3");
|
||||
doNothing().when(mailgunEmailService).sendNewUser(any());
|
||||
|
||||
UserResponseWithToken token = authService.signUpNewUser(request);
|
||||
|
||||
@@ -531,8 +529,6 @@ class AuthServiceTest {
|
||||
existing.setEmail(email);
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
|
||||
|
||||
doNothing().when(mailgunEmailService).sendNewUser(existing);
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> authService.resendEmailConfirmation(email));
|
||||
verify(mailgunEmailService, times(0)).sendNewUser(existing);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ class HomeServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
homeService = new HomeService(taskService);
|
||||
homeService = new HomeService(taskService, noteService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Get top tasks tag should return up to 5 most used tags")
|
||||
void getTopTasksTag_shouldReturnTopTags() {
|
||||
@DisplayName("Get tasks tags should return all tags ordered alphabetically")
|
||||
void getTopTasksTag_shouldReturnAllTagsAlphabetically() {
|
||||
TaskResponse task1 =
|
||||
new TaskResponse(1L, "Task 1", false, false, null, null, null, "tag1", List.of());
|
||||
TaskResponse task2 =
|
||||
@@ -54,15 +54,11 @@ class HomeServiceTest {
|
||||
when(taskService.getTasksByFilter("all"))
|
||||
.thenReturn(List.of(task1, task2, task3, task4, task5, task6, task7, task8));
|
||||
|
||||
List<String> topTags = homeService.getTopTasksTag();
|
||||
List<String> tags = homeService.getTopTasksTag();
|
||||
|
||||
Assertions.assertNotNull(topTags);
|
||||
Assertions.assertEquals(5, topTags.size());
|
||||
Assertions.assertTrue(topTags.contains("tag1"));
|
||||
Assertions.assertTrue(topTags.contains("tag2"));
|
||||
Assertions.assertTrue(topTags.contains("tag3"));
|
||||
Assertions.assertTrue(topTags.contains("tag4"));
|
||||
Assertions.assertTrue(topTags.contains("tag5"));
|
||||
Assertions.assertNotNull(tags);
|
||||
Assertions.assertEquals(6, tags.size());
|
||||
Assertions.assertEquals(List.of("tag1", "tag2", "tag3", "tag4", "tag5", "tag6"), tags);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
|
||||
@@ -34,9 +34,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskServiceTest {
|
||||
|
||||
@Mock TaskRepository taskRepository;
|
||||
@@ -534,8 +534,6 @@ class TaskServiceTest {
|
||||
|
||||
String searchTerm = null;
|
||||
|
||||
when(taskRepository.findAllBySearchTerm(null, USER_ID)).thenReturn(List.of());
|
||||
|
||||
List<TaskResponse> responses = taskService.searchTasks(searchTerm);
|
||||
|
||||
assertNotNull(responses);
|
||||
|
||||
@@ -16,9 +16,9 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserSessionServiceTest {
|
||||
|
||||
// Test class generated by GitHub Copilot
|
||||
|
||||
+11
-1
@@ -170,4 +170,14 @@ docker run --rm -p 8080:8080 \
|
||||
-e CORS_ALLOWED_ORIGINS=http://localhost:5000 \
|
||||
-e SERVER_SERVLET_CONTEXT_PATH=/ \
|
||||
--network host \
|
||||
docker.io/rmcampos/tasknote:api-latest
|
||||
docker.io/rmcampos/tasknote:api-latest
|
||||
|
||||
## Running a single class test file
|
||||
|
||||
```
|
||||
# For a single class
|
||||
mvn test -P test -Dtest=YourTestClassName
|
||||
|
||||
# For a single class method
|
||||
mvn test -P test -Dtest=YourTestClassName#method
|
||||
```
|
||||
Reference in New Issue
Block a user