Compare commits

..
Author SHA1 Message Date
rmcampos 4e167f9a85 chore: add missing files and improve Dockerfile 2026-02-11 11:03:02 -03:00
rmcampos ee2fb62036 feat: improve markdown rendering and add home filter context 2026-02-11 10:57:43 -03:00
github-actions[bot] 5eb9ee5ced chore: bump api version to 7 [skip ci] 2026-02-10 20:53:37 +00:00
rmcampos 2679cf61ac fix: backend null pointer exception 2026-02-10 17:52:45 -03:00
github-actions[bot] 49baf4db77 chore: bump api version to 6 [skip ci] 2026-02-10 20:38:14 +00:00
rmcamposandGitHub c1db12be40 feat: include notes tags (#13) 2026-02-10 17:37:31 -03:00
rmcamposGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Ricardo Campos
1eab863fca feat: Add tag suggestion dropdown for Notes and Tasks (#12)
* feat: add tag suggestion dropdown for NoteAdd and TaskAdd views

Implement a tag suggestion dropdown in both NoteAdd and TaskAdd components.
When the tag input is focused, available tags are fetched from the API
(GET /rest/home/tasks/tags) and displayed in a filterable dropdown list.
Users can select an existing tag or type a custom one.

Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>

* chore: fix lint issue

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>
2026-02-10 17:35:26 -03:00
rmcampos ed6738381a fix: test CI command 2026-02-10 17:01:03 -03:00
rmcamposGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Ricardo Campos
b423082b1f fix: Keep filter between actions in Home view (#9)
* fix: keep filter selection between actions in Home view

When a user selects a filter (Tasks, Notes, or a tag) and performs an
action like marking a task as done or deleting a note, the filter is now
preserved after the data reload.

- Extract applyFilter() helper that accepts explicit task/note arrays to
  avoid stale state closure issues
- loadAllTasks() now re-applies the current selectedOption and filterText
  after fetching fresh data instead of resetting to 'everything'
- loadAllNotes() now re-applies the current filter after fetching fresh data
- Add 3 new tests verifying filter persistence after actions

Fixes #8

Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>

* feat: fix race condition as per Claude review

* ci: add CI to client

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>
2026-02-10 16:57:44 -03:00
github-actions[bot] ab34a310c4 chore: bump api version to 5 [skip ci] 2026-02-10 16:00:45 +00:00
rmcamposGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Ricardo CamposClaude Sonnet 4.5
ee916d02b9 feat: remove 5-tag limit and return all tags alphabetically (#7)
* feat: remove 5-tag limit and return all tags alphabetically

The /rest/home/tasks/tags endpoint previously returned only the top 5
most-used tags. This change removes that restriction so all existing
tags are returned, sorted alphabetically.

Fixes #6

Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* chore: fix test issue and add log version to the right place

* ci: add server ci

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:00:10 -03:00
rmcamposGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Ricardo Campos
ee38fa256d docs: update CLAUDE.md with current project context (#5)
* docs: update CLAUDE.md with current project context

- Remove non-existent angular/ directory reference
- Update Java version from 17 to 21
- Update Spring Boot version to 3.5.9
- Remove outdated OpenAPI/Swagger documentation reference
- Update docker-compose file references (dev vs prod)
- Add React 19 and React Router v7 information
- Add Taskfile.yml to development workflow section
- Reorganize development workflow with manual and Taskfile options

Fixes #4

Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>

* fix: update Taskfile commands in CLAUDE.md

- Change task dev to task dev-run
- Change task prod to task prod-up
- Split task docker-build into task docker-build-api and task docker-build-web
- Add task -l command to list all available tasks

Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>
2026-02-05 16:01:34 -03:00
rmcamposandGitHub 1d7b10ec4d Merge pull request #2 from RMCampos/add-claude-github-actions-1770309711613
Add claude GitHub actions 1770309711613
2026-02-05 13:47:45 -03:00
rmcampos e4454f913f "Update Claude Code Review workflow" 2026-02-05 13:41:54 -03:00
rmcampos 7ea127c4a9 "Update Claude PR Assistant workflow" 2026-02-05 13:41:53 -03:00
rmcamposandGitHub 7fffb163d1 Merge pull request #1 from RMCampos/add-claude-github-actions-1770309101716
Add claude GitHub actions 1770309101716
2026-02-05 13:35:04 -03:00
rmcampos bd3a6c6f18 "Claude Code Review workflow" 2026-02-05 13:31:44 -03:00
rmcampos 111eb4686b "Claude PR Assistant workflow" 2026-02-05 13:31:43 -03:00
rmcampos da0432a734 chore: bump client dependencies 2026-02-05 13:05:03 -03:00
github-actions[bot] d016d20850 chore: bump api version to 4 [skip ci] 2026-01-18 21:23:25 +00:00
rmcampos 332da7de94 Merge branch 'main' of github.com:RMCampos/tasknote 2026-01-18 18:22:46 -03:00
rmcampos 6c92a5ca91 chore: add job to deploy and fix tests 2026-01-18 18:22:22 -03:00
41 changed files with 1558 additions and 642 deletions
+44
View File
@@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
+50
View File
@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+51
View File
@@ -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
+3
View File
@@ -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 }}"
+3
View File
@@ -85,3 +85,6 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a api-v${{ steps.version.outputs.version }} -m "Release API v${{ steps.version.outputs.version }}"
git push origin api-v${{ steps.version.outputs.version }}
- name: Trigger Dokploy deployment
run: curl -X POST "${{ vars.DOKPLOY_WEBHOOK_API }}"
+43
View File
@@ -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: '21'
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
+18 -6
View File
@@ -40,12 +40,13 @@ bash tools/check-backend.sh # Run backend quality checks
### Monorepo Structure
- `client/` - React TypeScript frontend (Vite + Vitest)
- `server/` - Java Spring Boot REST API
- `angular/` - Alternative Angular frontend (separate implementation)
- `server/` - Java Spring Boot REST API
- `tools/` - Development and deployment scripts
- `docker-compose.yml` - Multi-service development environment
- `docker-compose.dev.yml` / `docker-compose.prod.yml` - Multi-service development and production environments
- `Taskfile.yml` - Task automation for docker builds and workflows
### Frontend Architecture (client/)
- **Framework**: React 19 with React Router v7
- **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)
@@ -55,11 +56,10 @@ bash tools/check-backend.sh # Run backend quality checks
- **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
- **Framework**: Spring Boot 3.5.9 with Java 21
- **Security**: Spring Security with JWT authentication
- **Database**: PostgreSQL with JPA/Hibernate and Flyway migrations
- **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
@@ -78,11 +78,23 @@ bash tools/check-backend.sh # Run backend quality checks
## Development Workflow
### Manual Setup
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
### Using Taskfile
Alternatively, use the Taskfile.yml for automated workflows:
```bash
task dev-run # Start development environment (docker-compose.dev.yml)
task prod-up # Start production environment (docker-compose.prod.yml)
task docker-build-api # Build server Docker image
task docker-build-web # Build app Docker image
task -l # List all available tasks
```
### Quality Checks
Always run quality checks before submitting changes:
- Frontend: `bash tools/check-frontend.sh`
- Backend: `bash tools/check-backend.sh`
+1 -1
View File
@@ -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
+687 -438
View File
File diff suppressed because it is too large Load Diff
+21 -19
View File
@@ -13,22 +13,23 @@
"private": true,
"dependencies": {
"@popperjs/core": "^2.11.8",
"@types/node": "^25.0.8",
"@types/node": "^25.2.1",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"@vitejs/plugin-react": "^5.1.3",
"bootstrap": "^5.3.8",
"dompurify": "^3.3.1",
"i18next": "^25.7.4",
"react": "^19.2.3",
"i18next": "^25.8.4",
"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.0",
"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/compat": "^2.0.2",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@stylistic/eslint-plugin": "^5.7.0",
"@stylistic/eslint-plugin": "^5.7.1",
"@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.13",
"@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-jsdoc": "^62.5.2",
"eslint-plugin-n": "^17.23.2",
"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.0.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.54.0",
"vitest": "^4.0.18"
}
}
+1 -1
View File
@@ -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
+154 -63
View File
@@ -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(() => {
@@ -2,6 +2,7 @@ import React 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 = {
@@ -41,7 +42,7 @@ const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
</Modal.Title>
</Modal.Header>
<Modal.Body className="markdown-modal">
<Markdown>{props.markdownText}</Markdown>
<Markdown remarkPlugins={[remarkGfm]}>{props.markdownText}</Markdown>
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={props.onHide}>
+143 -2
View File
@@ -1,15 +1,156 @@
.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;
}
}
+12
View File
@@ -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;
+49
View File
@@ -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;
+6 -3
View File
@@ -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>
);
+28 -15
View File
@@ -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
+80 -14
View File
@@ -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>
+80 -14
View File
@@ -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>
+1 -1
View File
@@ -45,7 +45,7 @@ services:
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
+1 -1
View File
@@ -11,7 +11,7 @@
<groupId>br.com.tasknoteapp</groupId>
<artifactId>server</artifactId>
<version>3</version>
<version>7</version>
<name>tasknote-api</name>
<description>Java backend REST API to serve TaskNote frontend client</description>
@@ -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());
}
}
@@ -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() {
@@ -1,12 +1,20 @@
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.lang.Nullable;
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 +22,25 @@ public class AppVersionService {
*
* @param buildProperties the build properties injected by Spring Boot
*/
public AppVersionService(BuildProperties buildProperties) {
public AppVersionService(@Autowired(required = false) @Nullable 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();
}
}
@@ -23,6 +23,7 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class AuthenticationControllerTest {
@Autowired private MockMvc mockMvc;
@@ -27,6 +27,7 @@ class HomeControllerTest {
@MockitoBean private HomeService homeService;
@Test
@SuppressWarnings("null")
@DisplayName("Get task tags following the happy path it should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void getTasksTags_happyPath_shouldSucceed() throws Exception {
@@ -45,6 +46,7 @@ class HomeControllerTest {
}
@Test
@SuppressWarnings("null")
@DisplayName("Get task tags not authorized it should fail")
void getTasksTags_notAuthorized_shouldSucceed() throws Exception {
mockMvc
@@ -32,6 +32,7 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class NoteControllerTest {
@Autowired private MockMvc mockMvc;
@@ -30,6 +30,7 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class TaskControllerTest {
@Autowired private MockMvc mockMvc;
@@ -23,6 +23,7 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class UserControllerTest {
@Autowired private MockMvc mockMvc;
@@ -22,6 +22,7 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class UserSessionControllerTest {
@Autowired private MockMvc mockMvc;
@@ -43,6 +43,7 @@ import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@SuppressWarnings("null")
@ExtendWith(SpringExtension.class)
class AuthServiceTest {
@@ -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
@@ -20,6 +20,7 @@ import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
/** Test class generated by Copilot. I had to tweak it, to make it work. */
@SuppressWarnings("null")
@ExtendWith(MockitoExtension.class)
class MailgunEmailServiceTest {
@@ -28,6 +28,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@SuppressWarnings("null")
@ExtendWith(MockitoExtension.class)
class NoteServiceTest {
@@ -36,6 +36,7 @@ import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.Mock;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@SuppressWarnings("null")
@ExtendWith(SpringExtension.class)
class TaskServiceTest {
@@ -18,6 +18,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@SuppressWarnings("null")
@ExtendWith(SpringExtension.class)
class UserSessionServiceTest {
@@ -26,6 +26,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.util.ReflectionTestUtils;
@SuppressWarnings("null")
@ExtendWith(MockitoExtension.class)
class JwtServiceImplTest {
+11 -1
View File
@@ -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
```