Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6bebc6779
|
||
|
|
1381e08273
|
||
|
|
a96a113f49
|
||
|
|
923617bdff
|
||
|
|
5ef96002cc
|
||
|
|
d08f611174
|
||
|
|
09462e9e84
|
||
|
|
4993b1e806 | ||
|
|
7b45e2ccbd | ||
|
|
1ebf89668d
|
||
|
|
d7cd922b94
|
||
|
|
c9916a8475
|
||
|
|
6803ae6ebc
|
||
|
|
231e091f5d | ||
|
|
749ddbcdad | ||
|
|
294e1b7a6a | ||
|
|
58dabc75a6
|
||
|
|
dd15837d4d | ||
|
|
a79e79c04f
|
||
|
|
488586be48
|
||
|
|
8901084fa9
|
||
|
|
466e255594
|
||
|
|
300b2e0576 | ||
|
|
0250b558cf | ||
|
|
891c526363 | ||
|
|
642eee6de1 | ||
|
|
4b04bfbbcf | ||
|
|
c9c2e97d06
|
||
|
|
acb08f7e14 | ||
|
|
4164b7dd97
|
||
|
|
0fc1cc3d65
|
||
|
|
3b591ac85e
|
||
|
|
b1cece9138
|
||
|
|
1ff3ba964c | ||
|
|
eea8313f30
|
||
|
|
419a33f7fc | ||
|
|
169f07801a | ||
|
|
a55a80e916 | ||
|
|
6272650ef9 | ||
|
|
54eeb66f0d | ||
|
|
a9f2b7b3a4
|
||
|
|
d6a7ee34ef
|
||
|
|
ce76f94237 |
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: client-updater
|
||||
description: A bot that updates the client software to the latest version.
|
||||
tools: [run_shell_command, read_file, write_file, replace, list_directory, glob, grep_search, ask_user]
|
||||
---
|
||||
# Instructions
|
||||
1. Navigate to the `client` directory.
|
||||
2. Execute the following command to check for updates:
|
||||
`npx npm-check-updates --target minor`
|
||||
3. Display the resulting list of available minor updates to the user.
|
||||
4. If there are packages to update:
|
||||
- Run `npx npm-check-updates --target minor -u` to update `package.json`.
|
||||
- Run `npm install` to update the `package-lock.json` and install the new versions.
|
||||
5. Run the validation script to ensure stability:
|
||||
`../tools/check-frontend.sh`
|
||||
6. If the validation passes:
|
||||
- Create a new branch (e.g., `update-deps-[date]`).
|
||||
- Commit the changes to `package.json` and `package-lock.json`.
|
||||
- Push and create a Pull Request (using `gh pr create` if available).
|
||||
7. If validation fails (lint, build, or test issues):
|
||||
- Diagnose the failure using `grep_search` and `read_file`.
|
||||
- Fix the issues, then retry the validation and PR steps.
|
||||
8. Report the final status and the PR link to the user.
|
||||
@@ -1,12 +0,0 @@
|
||||
description = "Create a formatted GitHub issue"
|
||||
prompt = """
|
||||
Act as a project manager. Based on the following input: {{args}}
|
||||
Create a GitHub issue using the `gh` CLI tool with this exact structure:
|
||||
- Title: [Feat/Bug] <short summary>
|
||||
- Body: A detailed description, technical requirements, and acceptance criteria.
|
||||
- Label: Automatically determine if it's 'bug', 'enhencement', or 'task'.
|
||||
|
||||
Once the user confirms the plan, execute the command:
|
||||
`gh issue create --title "[Type] Title" --body "..." --label "..."
|
||||
"""
|
||||
|
||||
@@ -48,9 +48,17 @@ jobs:
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
|
||||
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
|
||||
build-args: |
|
||||
VITE_BUILD=${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Log pushed images
|
||||
run: |
|
||||
echo "Pushed images:"
|
||||
echo " rmcampos/tasknote-app:candidate"
|
||||
echo " rmcampos/tasknote-app:${{ steps.version.outputs.tag }}"
|
||||
@@ -37,9 +37,15 @@ jobs:
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
|
||||
|
||||
- name: Tag and push candidate
|
||||
run: |
|
||||
docker tag rmcampos/tasknote-api:latest rmcampos/tasknote-api:candidate
|
||||
docker push rmcampos/tasknote-api:candidate
|
||||
|
||||
- name: Log pushed images
|
||||
run: |
|
||||
echo "Pushed images:"
|
||||
echo " rmcampos/tasknote-api:latest"
|
||||
echo " rmcampos/tasknote-api:candidate"
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Backend CD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/cd-backend.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: graalvm-25
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date +'%Y.%m.%d')
|
||||
TAG="api-v${DATE}.${{ github.run_number }}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
|
||||
- name: Build Docker image with Spring Boot
|
||||
working-directory: ./server
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: rmcampos/tasknote-api
|
||||
tags: |
|
||||
type=raw,value=${{ steps.version.outputs.tag }}
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
|
||||
- name: Tag and push Docker image
|
||||
run: |
|
||||
docker tag docker.io/rmcampos/tasknote-api:latest docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.tag }}
|
||||
docker push docker.io/rmcampos/tasknote-api:latest
|
||||
docker push docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Output Docker image URLs
|
||||
run: |
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "🚀 Docker Images Published Successfully!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "API Image:"
|
||||
echo " rmcampos/tasknote-api:${{ steps.version.outputs.tag }}"
|
||||
echo " rmcampos/tasknote-api:latest"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Frontend CD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'client/**'
|
||||
- '.github/workflows/frontend-cd.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/client/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./client
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date +'%Y.%m.%d')
|
||||
TAG="app-v${DATE}.${{ github.run_number }}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
run: |
|
||||
docker buildx create --name container-builder --driver docker-container --use --bootstrap || \
|
||||
docker buildx use container-builder
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: rmcampos/tasknote-app
|
||||
tags: |
|
||||
type=raw,value=${{ steps.version.outputs.tag }}
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
|
||||
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
|
||||
build-args: |
|
||||
VITE_BUILD=${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Output Docker image URLs
|
||||
run: |
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "🚀 Docker Images Published Successfully!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "App Image:"
|
||||
echo " rmcampos/tasknote-app:${{ steps.version.outputs.tag }}"
|
||||
echo " rmcampos/tasknote-app:latest"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
@@ -1,174 +0,0 @@
|
||||
name: Main CD-Deploy to Prod
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
backend_image:
|
||||
description: "Backend image tag (full image reference)"
|
||||
required: false
|
||||
frontend_image:
|
||||
description: "Frontend image tag (full image reference)"
|
||||
required: false
|
||||
apply:
|
||||
description: "Apply changes after plan"
|
||||
required: false
|
||||
default: "true"
|
||||
workflow_run:
|
||||
workflows: [ "Main CI-Backend", "Main CI-Frontend" ]
|
||||
types: [ completed ]
|
||||
|
||||
jobs:
|
||||
terraform-plan:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: easynode-debian
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Validate cluster access
|
||||
run: |
|
||||
kubectl cluster-info
|
||||
kubectl get namespace tasknote
|
||||
|
||||
- name: Determine deployment values
|
||||
id: deploy-vars
|
||||
run: |
|
||||
backend_image="${{ github.event.inputs.backend_image }}"
|
||||
frontend_image="${{ github.event.inputs.frontend_image }}"
|
||||
|
||||
latest_backend_tag_tmp="$(git tag --list 'api-v*' | sort -V | tail -n1)"
|
||||
latest_backend_tag="${latest_backend_tag_tmp#api-v}"
|
||||
latest_frontend_tag="$(git tag --list 'app-v*' | sort -V | tail -n1)"
|
||||
|
||||
if [ -z "$latest_backend_tag" ]; then
|
||||
echo "No backend tag found matching [0-9]*" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$latest_frontend_tag" ]; then
|
||||
echo "No frontend tag found matching app-v*" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$backend_image" ]; then
|
||||
backend_image="rmcampos/tasknote-api:$latest_backend_tag"
|
||||
fi
|
||||
if [ -z "$frontend_image" ]; then
|
||||
frontend_image="rmcampos/tasknote-app:$latest_frontend_tag"
|
||||
fi
|
||||
|
||||
echo "Resolved backend_image=$backend_image"
|
||||
echo "Resolved frontend_image=$frontend_image"
|
||||
|
||||
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Terraform Fmt -check -diff
|
||||
working-directory: terraform
|
||||
run: terraform fmt -check -diff
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Validate
|
||||
working-directory: terraform
|
||||
run: terraform validate
|
||||
|
||||
- name: Terraform Plan
|
||||
id: check-changes
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
timeout 1m terraform plan -input=false -out=tfplan \
|
||||
-var="db_user=${{ secrets.DB_USER }}" \
|
||||
-var="db_password=${{ secrets.DB_PASSWORD }}" \
|
||||
-var="db_name=${{ secrets.DB_NAME }}" \
|
||||
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
|
||||
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
|
||||
-var="r2_access_key=${{ secrets.R2_ACCESS_KEY_ID }}" \
|
||||
-var="r2_secret_key=${{ secrets.R2_SECRET_ACCESS_KEY }}" \
|
||||
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
|
||||
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: easynode-debian
|
||||
needs: terraform-plan
|
||||
if: >
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
|
||||
&& needs.terraform-plan.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: production
|
||||
url: https://tasknote.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: timeout 1m terraform apply tfplan
|
||||
@@ -1,142 +0,0 @@
|
||||
name: Pull Request CD-Deploy to Staging
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: [ "Pull Request CI-Backend", "Pull Request CI-Frontend" ]
|
||||
types: [ completed ]
|
||||
|
||||
jobs:
|
||||
terraform-plan-stg:
|
||||
name: Plan changs to staging
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: easynode-debian
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Show Terraform provider versions
|
||||
working-directory: terraform-stg
|
||||
run: terraform version
|
||||
|
||||
- name: Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Validate cluster access
|
||||
run: |
|
||||
kubectl cluster-info
|
||||
kubectl get namespace tasknote-stg
|
||||
|
||||
- name: Determine deployment values
|
||||
id: deploy-vars
|
||||
run: |
|
||||
backend_image="rmcampos/tasknote-api:candidate"
|
||||
frontend_image="rmcampos/tasknote-app:candidate"
|
||||
|
||||
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Terraform Fmt -check -diff
|
||||
working-directory: terraform-stg
|
||||
run: terraform fmt -check -diff
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Validate
|
||||
working-directory: terraform-stg
|
||||
run: terraform validate
|
||||
|
||||
- name: Terraform Plan
|
||||
id: check-changes
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
timeout 3m terraform plan -input=false -out=tfplan \
|
||||
-var="db_user=${{ secrets.DB_USER }}" \
|
||||
-var="db_password=${{ secrets.DB_PASSWORD }}" \
|
||||
-var="db_name=${{ secrets.DB_NAME }}" \
|
||||
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
|
||||
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
|
||||
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
|
||||
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}" \
|
||||
-var="deploy_version=${{ github.run_id }}"
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform-stg/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: easynode-debian
|
||||
needs: terraform-plan-stg
|
||||
if: needs.terraform-plan-stg.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://tasknote-stg.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform-stg
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
KUBE_CONFIG_PATH: ~/.kube/config
|
||||
run: timeout 1m terraform apply tfplan
|
||||
@@ -1,90 +0,0 @@
|
||||
name: Main CI-Backend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'server/**/*.java'
|
||||
- 'server/**/*.xml'
|
||||
- 'server/pom.xml'
|
||||
- '.github/workflows/ci-main-backend.yml'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: graalvm-25
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Increment version in pom.xml
|
||||
id: version
|
||||
working-directory: ./server
|
||||
run: |
|
||||
CURRENT_VERSION=$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout)
|
||||
echo "Current version: ${CURRENT_VERSION}"
|
||||
NEW_VERSION=$((CURRENT_VERSION + 1))
|
||||
echo "New version: ${NEW_VERSION}"
|
||||
./mvnw versions:set -DnewVersion=${NEW_VERSION} -DgenerateBackupFiles=false -q
|
||||
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
git add server/pom.xml
|
||||
git commit -m "chore: bump api version to ${{ steps.version.outputs.version }} [skip ci]"
|
||||
git push
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Find PR number
|
||||
id: find_pr
|
||||
run: |
|
||||
PR_NUMBER=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
"https://lightroasted.vps-kinghost.net/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull" \
|
||||
| jq -r 'if type == "object" and .number != null then .number | tostring else empty end')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
|
||||
PR_NUMBER="candidate"
|
||||
else
|
||||
PR_NUMBER="pr-${PR_NUMBER}"
|
||||
fi
|
||||
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Promote Docker image
|
||||
run: |
|
||||
docker pull docker.io/rmcampos/tasknote-api:${{ steps.find_pr.outputs.tag }}
|
||||
docker tag docker.io/rmcampos/tasknote-api:${{ steps.find_pr.outputs.tag }} docker.io/rmcampos/tasknote-api:latest
|
||||
docker tag docker.io/rmcampos/tasknote-api:${{ steps.find_pr.outputs.tag }} docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.version }}
|
||||
docker push docker.io/rmcampos/tasknote-api:latest
|
||||
docker push docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
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 }}
|
||||
@@ -1,78 +0,0 @@
|
||||
name: Main CI-Frontend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'client/**/*.html'
|
||||
- 'client/**/*.png'
|
||||
- 'client/**/*.json'
|
||||
- 'client/**/*.txt'
|
||||
- 'client/**/*.ts'
|
||||
- 'client/**/*.tsx'
|
||||
- 'client/**/*.js'
|
||||
- 'client/Dockerfile'
|
||||
- 'client/Caddyfile'
|
||||
- '.github/workflows/ci-main-frontend.yml'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date +'%Y.%m.%d')
|
||||
TAG="app-v${DATE}.${{ github.run_number }}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
run: docker buildx inspect --bootstrap
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Find PR number
|
||||
id: find_pr
|
||||
run: |
|
||||
PR_NUMBER=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
"https://lightroasted.vps-kinghost.net/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull" \
|
||||
| jq -r 'if type == "object" and .number != null then .number | tostring else empty end')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
|
||||
PR_NUMBER="candidate"
|
||||
else
|
||||
PR_NUMBER="pr-${PR_NUMBER}"
|
||||
fi
|
||||
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Promote Docker image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag rmcampos/tasknote-app:latest \
|
||||
--tag rmcampos/tasknote-app:${{ steps.version.outputs.tag }} \
|
||||
rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
@@ -1,99 +0,0 @@
|
||||
name: Pull Request CI-Backend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'server/**/*.java'
|
||||
- 'server/**/*.xml'
|
||||
- 'server/pom.xml'
|
||||
- 'server/**/*.yml'
|
||||
- '.github/workflows/ci-pr-backend.yml'
|
||||
|
||||
jobs:
|
||||
run-checks:
|
||||
name: Checks
|
||||
runs-on: graalvm-25
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-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
|
||||
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: graalvm-25
|
||||
needs: ["run-checks"]
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image with Spring Boot
|
||||
working-directory: ./server
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
|
||||
- name: Tag and push Docker image
|
||||
run: |
|
||||
docker tag docker.io/rmcampos/tasknote-api:latest docker.io/rmcampos/tasknote-api:candidate
|
||||
docker tag docker.io/rmcampos/tasknote-api:latest docker.io/rmcampos/tasknote-api:pr-${{ github.event.pull_request.number }}
|
||||
docker push docker.io/rmcampos/tasknote-api:candidate
|
||||
docker push docker.io/rmcampos/tasknote-api:pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Create Gitea deployment for staging
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://lightroasted.vps-kinghost.net/api/v1/repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
|
||||
-d "{\"context\": \"staging/deploy\", \"state\": \"success\", \"description\": \"PR #${{ github.event.pull_request.number }} staging ready\", \"target_url\": \"https://tasknote-stg.darkroasted.vps-kinghost.net\"}"
|
||||
@@ -1,126 +0,0 @@
|
||||
name: Pull Request CI-Frontend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'client/**/*.html'
|
||||
- 'client/**/*.png'
|
||||
- 'client/**/*.json'
|
||||
- 'client/**/*.txt'
|
||||
- 'client/**/*.ts'
|
||||
- 'client/**/*.tsx'
|
||||
- 'client/**/*.js'
|
||||
- 'client/Dockerfile'
|
||||
- 'client/Caddyfile'
|
||||
- '.github/workflows/ci-pr-frontend.yml'
|
||||
|
||||
jobs:
|
||||
run-checks:
|
||||
name: Checks
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/client/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Debug cache env
|
||||
run: |
|
||||
echo "CACHE_URL=${ACTIONS_CACHE_URL}"
|
||||
echo "RUNTIME_URL=${ACTIONS_RUNTIME_URL}"
|
||||
curl -s -w "\nHTTP: %{http_code}\n" \
|
||||
-H "Authorization: Bearer ${ACTIONS_RUNTIME_TOKEN}" \
|
||||
"${ACTIONS_CACHE_URL}_apis/artifactcache/cache?keys=test&version=test"
|
||||
|
||||
- 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
|
||||
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: easynode-debian
|
||||
needs: ["run-checks"]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
run: docker buildx inspect --bootstrap
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: rmcampos/tasknote-app
|
||||
tags: |
|
||||
type=raw,value=candidate
|
||||
type=raw,value=pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date +'%Y.%m.%d')
|
||||
TAG="app-v${DATE}.${{ github.run_number }}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
|
||||
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
|
||||
build-args: |
|
||||
VITE_BUILD=${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Create Gitea deployment for staging
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://lightroasted.vps-kinghost.net/api/v1/repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
|
||||
-d "{\"context\": \"staging/deploy\", \"state\": \"success\", \"description\": \"PR #${{ github.event.pull_request.number }} staging ready\", \"target_url\": \"https://tasknote-stg.darkroasted.vps-kinghost.net\"}"
|
||||
@@ -0,0 +1,74 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ['**/*']
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
name: Build Backend
|
||||
runs-on: graalvm-25
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-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
|
||||
|
||||
build-frontend:
|
||||
name: Build Frontend
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/client/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-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
|
||||
@@ -0,0 +1,134 @@
|
||||
name: Deploy to Prod
|
||||
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
backend_image:
|
||||
description: "Backend image tag (full image reference)"
|
||||
required: false
|
||||
frontend_image:
|
||||
description: "Frontend image tag (full image reference)"
|
||||
required: false
|
||||
apply:
|
||||
description: "Apply changes after plan"
|
||||
required: false
|
||||
default: "true"
|
||||
workflow_run:
|
||||
workflows: [ "Backend CD", "Frontend CD" ]
|
||||
types: [ completed ]
|
||||
|
||||
jobs:
|
||||
terraform-plan:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: easynode-debian
|
||||
outputs:
|
||||
has_changes: ${{ steps.check-changes.outputs.has_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Setup Doppler CLI
|
||||
uses: dopplerhq/cli-action@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
doppler run --config prd -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Validate cluster access
|
||||
run: |
|
||||
kubectl cluster-info
|
||||
kubectl get namespace tasknote
|
||||
|
||||
- name: Determine deployment values
|
||||
id: deploy-vars
|
||||
run: |
|
||||
backend_image="${{ github.event.inputs.backend_image }}"
|
||||
frontend_image="${{ github.event.inputs.frontend_image }}"
|
||||
|
||||
latest_backend_tag="$(git tag --list 'api-v*' | sort -V | tail -n1)"
|
||||
echo "latest backend tag=$latest_backend_tag"
|
||||
|
||||
latest_frontend_tag="$(git tag --list 'app-v*' | sort -V | tail -n1)"
|
||||
echo "latest frontend tag=$latest_frontend_tag"
|
||||
|
||||
if [ -z "$backend_image" ]; then
|
||||
backend_image="docker.io/rmcampos/tasknote-api:$latest_backend_tag"
|
||||
fi
|
||||
if [ -z "$frontend_image" ]; then
|
||||
frontend_image="docker.io/rmcampos/tasknote-app:$latest_frontend_tag"
|
||||
fi
|
||||
|
||||
echo "Resolved backend_image=$backend_image"
|
||||
echo "Resolved frontend_image=$frontend_image"
|
||||
|
||||
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Terraform Fmt -check -diff
|
||||
working-directory: terraform
|
||||
run: terraform fmt -check -diff
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: doppler run --config prd -- terraform init -input=false
|
||||
|
||||
- name: Terraform Validate
|
||||
working-directory: terraform
|
||||
run: terraform validate
|
||||
|
||||
- name: Terraform Plan
|
||||
id: check-changes
|
||||
working-directory: terraform
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
BACKEND_IMAGE: ${{ steps.deploy-vars.outputs.backend_image }}
|
||||
FRONTEND_IMAGE: ${{ steps.deploy-vars.outputs.frontend_image }}
|
||||
run: |
|
||||
doppler run --config prd -- bash -c '
|
||||
TF_VAR_db_user="$DB_USER" \
|
||||
TF_VAR_db_password="$DB_PASSWORD" \
|
||||
TF_VAR_db_name="$DB_NAME" \
|
||||
TF_VAR_security_key="$SECURITY_KEY" \
|
||||
TF_VAR_mailgun_apikey="$MAILGUN_APIKEY" \
|
||||
TF_VAR_r2_access_key="$AWS_ACCESS_KEY_ID" \
|
||||
TF_VAR_r2_secret_key="$AWS_SECRET_ACCESS_KEY" \
|
||||
TF_VAR_backend_image="$BACKEND_IMAGE" \
|
||||
TF_VAR_frontend_image="$FRONTEND_IMAGE" \
|
||||
timeout 1m terraform plan -input=false -out=tfplan
|
||||
'
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform
|
||||
if: steps.check-changes.outputs.has_changes == 'true'
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: doppler run --config prd -- timeout 2m terraform apply tfplan
|
||||
@@ -25,10 +25,10 @@
|
||||
- Frontend local dev: run from `client/` with `npm start`; backend local dev: run from `server/` with `./mvnw spring-boot:run`.
|
||||
|
||||
## CI/CD and release behavior
|
||||
- PR workflows (`.github/workflows/ci-pr-frontend.yml`, `.github/workflows/ci-pr-backend.yml`) run checks then push `:candidate` and `:pr-<N>` images to GHCR.
|
||||
- Main workflows (`.github/workflows/ci-main-frontend.yml`, `.github/workflows/ci-main-backend.yml`) push versioned tags (`app-v<date>.<run>` / `api-v<pom-version>`) + `latest`; backend workflow also increments `server/pom.xml` version.
|
||||
- Staging deploy workflow (`.github/workflows/cd-pr.yml`) triggers on completion of either PR CI workflow and applies Terraform in `terraform-stg/` using a plan→apply split.
|
||||
- Production deploy workflow (`.github/workflows/cd-main.yml`) triggers on completion of either Main CI workflow and applies Terraform in `terraform/` using a plan→apply split; `apply` can be skipped if there are no Terraform changes.
|
||||
- PR workflows (`.github/workflows/drop-ci-pr-frontend.yml`, `.github/workflows/drop-ci-pr-backend.yml`) run checks then push `:candidate` and `:pr-<N>` images to GHCR.
|
||||
- Main workflows (`.github/workflows/drop-ci-main-frontend.yml`, `.github/workflows/drop-ci-main-backend.yml`) push versioned tags (`app-v<date>.<run>` / `api-v<pom-version>`) + `latest`; backend workflow also increments `server/pom.xml` version.
|
||||
- Staging deploy workflow (`.github/workflows/drop-cd-pr.yml`) triggers on completion of either PR CI workflow and applies Terraform in `terraform-stg/` using a plan→apply split.
|
||||
- Production deploy workflow (`.github/workflows/deploy.yml`) triggers on completion of either Main CI workflow and applies Terraform in `terraform/` using a plan→apply split; `apply` can be skipped if there are no Terraform changes.
|
||||
- Infra wiring (secrets, services, ingress, image vars) is defined in `terraform/main.tf`; an alternative GCP target is under `terraform-gcp/`.
|
||||
|
||||
## Project conventions to preserve
|
||||
|
||||
+121
-2
@@ -7,13 +7,132 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## api-v29 && app
|
||||
## 2026-08-07
|
||||
|
||||
### Added
|
||||
- Link to the build number to point to the changelog file. (build 201)
|
||||
|
||||
### Changed
|
||||
- Bumped Spring Boot to 4.1.0
|
||||
- All deps to latest version in client for patch target. (build 201)
|
||||
- All deps to latest version in client for minor target. (build 201)
|
||||
- Development files for ngrok locally. (build 201)
|
||||
|
||||
### Fixed
|
||||
- Buildx error in build phase in CI. (build 201)
|
||||
|
||||
### Removed
|
||||
- Lingering files from previous CI/CD workflows. (build 201)
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/tasknote-app:app-v2026.08.07.201
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-28
|
||||
|
||||
### Added
|
||||
- Option to archive notes.
|
||||
|
||||
### Changed
|
||||
- Notes should be archived before deleting.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/tasknote-app:app-v2026.07.28.195
|
||||
docker pull rmcampos/tasknote-api:api-v2026.07.28.194
|
||||
```
|
||||
|
||||
## 2026-07-23
|
||||
|
||||
### Added
|
||||
- Section for completed tasks in the home page..
|
||||
- Icons in tasks and notes to differentiate them.
|
||||
- Modal confirming before delete tasks and notes.
|
||||
|
||||
### Changed
|
||||
- Completed tasks are now kept in the database, unless deleted.
|
||||
- Buttons in home screen notes view to match the system design.
|
||||
- Add task form to be easier to see and better structured.
|
||||
- Delete my account buttons layout to match the system design.
|
||||
- Loaded tasks now has a light yellow styling.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/tasknote-app:app-v2026.07.23.190
|
||||
docker pull rmcampos/tasknote-api:api-v2026.07.23.189
|
||||
```
|
||||
|
||||
### Fixed
|
||||
- Dropped the untagged tag from loading in the add notes and tasks form.
|
||||
|
||||
## 2026-07-22
|
||||
|
||||
### Added
|
||||
- Button to save notes from the preview modal. Closes [#15](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/issues/15)
|
||||
|
||||
### Changed
|
||||
- Removed deployments to staging in PR pipelines. PR only runs CI now. Closes [#14](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/issues/14)
|
||||
|
||||
### Removed
|
||||
- Old files from project and moved scripts to `tools` folder.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/tasknote-app:app-v2026.07.22.161
|
||||
docker pull rmcampos/tasknote-api:api-v2026.07.22.169
|
||||
```
|
||||
|
||||
## 2026-07-20
|
||||
|
||||
### Changed
|
||||
- Labels in tasks due date to use the time ago format.
|
||||
- Bumped all minor deps in the frontend.
|
||||
|
||||
### Fixed
|
||||
- Background image position in landing, login and register pages.
|
||||
|
||||
### Removed
|
||||
- React Date Picker dependency in favor of regular browser input date UI.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/tasknote-app:app-v2026.07.20.161
|
||||
```
|
||||
|
||||
## 2026-07-01
|
||||
|
||||
### Added
|
||||
- Support for `Draft` notes and tasks.
|
||||
- Memory for open notes in the home page, if a tab is closed, the app will remember.
|
||||
|
||||
### Fixed
|
||||
- Frontend app build version release getting lost in workflows.
|
||||
|
||||
### Security
|
||||
- Addressed a list of critical security issues including validations, logging, and passwords.
|
||||
|
||||
### Docker images
|
||||
- `rmcampos/tasknote-app:app-v2026.07.01.140`
|
||||
|
||||
### Changed
|
||||
- Bumped client minor and major dependencies.
|
||||
|
||||
### Docker images
|
||||
- `rmcampos/tasknote-app:app-v2026.06.25.102`
|
||||
|
||||
## api-v32 && app-v2026.06.15.97 - 2026-06-15
|
||||
|
||||
### Changed
|
||||
- Bumped Spring Boot to 4.0.7
|
||||
- CI/CD workflow files updated to run on Gitea.
|
||||
- Container registry switched to Docker Hub.
|
||||
|
||||
### Docker images
|
||||
- [rmcampos/tasknote-api:32](https://hub.docker.com/layers/rmcampos/tasknote-api/32/images/sha256-4b719a08dbed4a9d4a6eece0059573954ee5193ab8247787fb0e30c037f6b1c6)
|
||||
- [rmcampos/tasknote-app:app-v2026.06.15.97](https://hub.docker.com/layers/rmcampos/tasknote-app/app-v2026.06.15.97/images/sha256-945a215a7105e34f97ab8e43094092e157156c0b557364260c019c4036cf845d)
|
||||
|
||||
## [app-v2026.06.08.22](https://github.com/RMCampos/tasknote/releases/tag/app-v2026.06.08.22) - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
+10
-4
@@ -9,7 +9,7 @@ If you want to contribute, please create a fork and a Merge Request. Take a look
|
||||
## Steps to Contribute
|
||||
|
||||
1. Fork the Project
|
||||
2. Clone it on your local (`git clone https://github.com/ricardo-campos-org/react-typescript-todolist`)
|
||||
2. Clone it on your local (`git clone https://lightroasted.vps-kinghost.net/rmcampos/tasknote.git`)
|
||||
3. Develop your amazing feature/changes
|
||||
4. Make sure your name is set (`git config user.name 'YOUR NAME'; git config user.email 'YOUR EMAIL'`)
|
||||
5. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
@@ -24,19 +24,25 @@ The easiest way of having the app up and running is using [Docker](https://www.d
|
||||
|
||||
1. Start the database engine (PostgreSQL)
|
||||
```sh
|
||||
bash tools/run-docker-db.sh
|
||||
task dev-run-db
|
||||
```
|
||||
2. Start the back-end engine (Java & Spring Boot)
|
||||
```sh
|
||||
bash tools/run-docker-server.sh
|
||||
task dev-run-api
|
||||
```
|
||||
3. Start the app server
|
||||
```sh
|
||||
bash tools/run-docker-client.sh
|
||||
task dev-run-web
|
||||
```
|
||||
|
||||
> Remember to follow up logs with 'docker ps' and 'docker logs -f <name>'
|
||||
|
||||
Or start all services at once with Docker Compose:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
If everything went well, you can head to [http://localhost:5000](http://localhost:5000) and create your user.
|
||||
|
||||
## 🦾 Automation
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# TaskNote
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](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)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-frontend.yml)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-backend.yml)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=cd-main.yml)
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
@@ -102,113 +101,50 @@ tasknote/
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- **Docker & Docker Compose** (recommended for easy setup)
|
||||
- **Node.js 20+** and **npm** (for frontend development)
|
||||
- **Java 25+** and **Maven 3.6+** (for backend development)
|
||||
- **PostgreSQL 15+** (if running without Docker)
|
||||
- [Docker](https://docs.docker.com/engine/install/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- [Task](https://taskfile.dev) (`brew install go-task` / `npm install -g @go-task/cli`)
|
||||
- [Doppler CLI](https://docs.doppler.com/docs/install-cli) (`brew install dopplerhq/cli/doppler`)
|
||||
|
||||
### Quick Start with Docker
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/rmcampos/tasknote.git
|
||||
cd tasknote
|
||||
```
|
||||
### Setup
|
||||
|
||||
2. **Start the database**
|
||||
```bash
|
||||
bash tools/run-docker-db.sh
|
||||
```
|
||||
|
||||
3. **Start the backend server**
|
||||
```bash
|
||||
bash tools/run-docker-server.sh
|
||||
```
|
||||
|
||||
4. **Start the frontend application**
|
||||
```bash
|
||||
bash tools/run-docker-client.sh
|
||||
```
|
||||
|
||||
5. **Access the application**
|
||||
- Frontend: http://localhost:5000
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
cd client
|
||||
npm install # Install dependencies
|
||||
npm start # Start development server (port 5000)
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
npm run lint # Run ESLint
|
||||
npm run lint:fix # Fix ESLint issues
|
||||
# 1. Authenticate with Doppler and link the project
|
||||
doppler login
|
||||
doppler setup # uses doppler.yaml to link to the shell-whats project
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
### Running locally
|
||||
|
||||
```bash
|
||||
cd server
|
||||
./mvnw spring-boot:run # Start development server
|
||||
./mvnw clean compile # Compile sources
|
||||
./mvnw spring-boot:build-image # Build Docker image
|
||||
./mvnw clean verify -Pnative # Build GraalVM native image
|
||||
task dev-run
|
||||
```
|
||||
|
||||
### Quality Checks
|
||||
Run quality checks before submitting changes:
|
||||
This exports the public vars from the `dev_tokens` Doppler config and starts the server in watch mode with secrets injected from `dev_secrets`. No `.env` file needed.
|
||||
|
||||
## Building the Docker images
|
||||
|
||||
```bash
|
||||
bash tools/check-frontend.sh # Frontend linting, testing, coverage
|
||||
bash tools/check-backend.sh # Backend compilation, tests, checkstyle
|
||||
# Build the backend
|
||||
task docker-build-api
|
||||
|
||||
# Build the frontend
|
||||
task docker-build-web
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
## 🧪 Testing & Checks
|
||||
|
||||
### Frontend Testing
|
||||
- **Framework**: Vitest with React Testing Library
|
||||
- **Coverage**: Comprehensive test coverage with reports in `client/coverage/`
|
||||
- **Commands**:
|
||||
```bash
|
||||
npm test # Run tests in watch mode
|
||||
npm run test:coverage # Generate coverage report
|
||||
```
|
||||
|
||||
```bash
|
||||
./tools/check-frontend.sh
|
||||
```
|
||||
|
||||
### Backend Testing
|
||||
- **Unit Tests**: Fast, isolated tests with mocked dependencies
|
||||
- **Integration Tests**: Full application context with test database
|
||||
- **Coverage**: JaCoCo reporting with 75% minimum requirement
|
||||
- **Commands**:
|
||||
```bash
|
||||
./mvnw test # Unit tests only
|
||||
./mvnw clean verify -Ptests # All tests with coverage
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Production Deployment
|
||||
The application supports multiple deployment strategies:
|
||||
|
||||
1. **Docker Containers** (recommended)
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
2. **Traditional JAR Deployment**
|
||||
```bash
|
||||
cd server && ./mvnw clean package
|
||||
java -jar target/tasknote-api.jar
|
||||
```
|
||||
|
||||
3. **GraalVM Native Image** (for optimal performance)
|
||||
```bash
|
||||
cd server && ./mvnw clean verify -Pnative
|
||||
./target/tasknote-api
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
- Database connection via environment variables
|
||||
- JWT secret configuration for production
|
||||
- Email service configuration for notifications
|
||||
- CORS settings for frontend domain
|
||||
```bash
|
||||
./tools/check-backend.sh
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -250,34 +186,6 @@ We welcome contributions from the community! This project follows the **Fork & M
|
||||
|
||||
For detailed setup instructions and development workflows, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## 👨💻 Developer
|
||||
|
||||
**Ricardo Campos** - Full-Stack Developer & Project Maintainer
|
||||
|
||||
- **GitHub**: [@RMCampos](https://github.com/RMCampos)
|
||||
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs)
|
||||
- **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:
|
||||
|
||||
- **Frontend Development**: React, TypeScript, modern CSS, responsive design
|
||||
- **Backend Development**: Java, Spring Boot, RESTful APIs, microservices
|
||||
- **DevOps & Infrastructure**: Docker, CI/CD, cloud deployment, monitoring
|
||||
- **Software Quality**: Testing strategies, code coverage, static analysis
|
||||
- **Open Source**: Community engagement, documentation, maintainership
|
||||
|
||||
The TaskNote project represents a commitment to clean code, comprehensive testing, and user-centered design principles.
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
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/rmcampos/tasknote/issues) for bugs or feature requests
|
||||
- **GitHub Discussions**: [Join discussions](https://github.com/rmcampos/tasknote/discussions) for general questions
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
+2
-5
@@ -2,17 +2,14 @@
|
||||
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
GREETING: Hello, World!
|
||||
|
||||
tasks:
|
||||
docker-build-web:
|
||||
desc: Build the tasknote-web prod-ready docker image, tagging it as candidate
|
||||
cmd: docker build --no-cache --build-arg VITE_BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" -t ghcr.io/rmcampos/tasknote/app:latest ./client
|
||||
cmd: docker build --no-cache --build-arg VITE_BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" -t rmcampos/tasknote-app:latest ./client
|
||||
|
||||
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 -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
|
||||
|
||||
prod-up-web:
|
||||
desc: Speed up the tasknote-web prod-like image, building it if required
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ RUN npm i --ignore-scripts --no-update-notifier --omit=dev && \
|
||||
|
||||
# Deploy container
|
||||
# Caddy serves static files
|
||||
FROM caddy:2.10.2-alpine
|
||||
FROM caddy:2.11.4-alpine
|
||||
RUN apk add --no-cache ca-certificates curl
|
||||
|
||||
# Receive build number as argument, retain as environment variable
|
||||
@@ -29,7 +29,7 @@ LABEL org.opencontainers.image.authors="Ricardo Campos <ricardompcampos@gmail.co
|
||||
org.opencontainers.image.title="TaskNoteApp client" \
|
||||
org.opencontainers.image.description="React Web app application" \
|
||||
org.opencontainers.image.version="${SOURCE_PR}" \
|
||||
org.opencontainers.image.source="https://github.com/ricardo-campos-org/react-typescript-todolist"
|
||||
org.opencontainers.image.source="https://lightroasted.vps-kinghost.net/rmcampos/tasknote"
|
||||
|
||||
# Copy files and run formatting
|
||||
COPY --from=build /app/dist/ /app/dist
|
||||
|
||||
Generated
+1360
-1348
File diff suppressed because it is too large
Load Diff
+27
-28
@@ -9,29 +9,28 @@
|
||||
"node",
|
||||
"nestjs"
|
||||
],
|
||||
"repository": "https://github.com/ricardo-campos-org/react-typescript-todolist",
|
||||
"repository": "https://lightroasted.vps-kinghost.net/rmcampos/tasknote",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"bootstrap": "^5.3.8",
|
||||
"dompurify": "^3.4.8",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "^19.2.7",
|
||||
"dompurify": "^3.4.13",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^19.2.8",
|
||||
"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.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.17.0",
|
||||
"react-router": "^8.3.0",
|
||||
"react-router-bootstrap": "^0.26.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite --host",
|
||||
@@ -66,30 +65,30 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/eslintrc": "^3.3.6",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/jest-dom": "^6.10.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/user-event": "^14.6.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-router-bootstrap": "^0.26.8",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"cypress": "^15.16.0",
|
||||
"eslint": "^9.39.4",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"cypress": "^15.20.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-jsdoc": "^62.9.0",
|
||||
"eslint-plugin-n": "^18.1.0",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"eslint-plugin-jsdoc": "^63.3.3",
|
||||
"eslint-plugin-n": "^18.2.2",
|
||||
"eslint-plugin-promise": "^7.3.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.6.0",
|
||||
"globals": "^17.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.100.0",
|
||||
"prettier": "^3.9.6",
|
||||
"sass": "^1.102.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vitest": "^4.1.8"
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Must be unique in a given SonarQube instance
|
||||
sonar.projectKey=ricardo-campos-org_react-typescript-todolist_client
|
||||
sonar.organization=ricardo-campos-org
|
||||
|
||||
# This is the name and version displayed in the SonarQube UI.
|
||||
# Was mandatory prior to SonarQube 6.1.
|
||||
sonar.projectName=tasknote-webapp
|
||||
#sonar.projectVersion=1.0
|
||||
|
||||
# Path is relative to the sonar-project.properties file.
|
||||
# Replace "\" by "/" on Windows.
|
||||
# This property is optional if sonar.modules is set.
|
||||
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||
sonar.typescript.tsconfigPaths=tsconfig.json
|
||||
sonar.sources=src/
|
||||
sonar.exclusions=src/__test__/**
|
||||
sonar.tests=src/__test__/
|
||||
sonar.verbose=false
|
||||
|
||||
# Encoding of the source code. Default is default system encoding
|
||||
sonar.sourceEncoding=UTF-8
|
||||
@@ -4,22 +4,22 @@ import { describe, expect, it } from 'vitest';
|
||||
import TaskTimeLeft from '../../components/TaskTimeLeft';
|
||||
|
||||
describe('TaskTimeLeft Component', () => {
|
||||
const renderComponent = (done: boolean) => {
|
||||
const renderComponent = (completed: boolean) => {
|
||||
return render(
|
||||
<TaskTimeLeft
|
||||
text="2 days left"
|
||||
done={done}
|
||||
completed={completed}
|
||||
tooltip="2025-03-20"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
it('should render the TaskTimeLeft component with text when task is not done', () => {
|
||||
it('should render the TaskTimeLeft component with text when task is not completed', () => {
|
||||
const { getByText } = renderComponent(false);
|
||||
expect(getByText('2 days left')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not render the TaskTimeLeft component when task is done', () => {
|
||||
it('should not render the TaskTimeLeft component when task is completed', () => {
|
||||
const { queryByText } = renderComponent(true);
|
||||
expect(queryByText('2 days left')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -4,32 +4,32 @@ import { describe, expect, it } from 'vitest';
|
||||
import TaskTitle from '../../components/TaskTitle';
|
||||
|
||||
describe('TaskTitle Component', () => {
|
||||
it('should render the TaskTitle component with high priority and done', () => {
|
||||
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} done={true} />);
|
||||
it('should render the TaskTitle component with high priority and completed', () => {
|
||||
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} completed={true} />);
|
||||
expect(container.querySelector('.task-title-icon')).toBeDefined();
|
||||
expect(container.querySelector('.text-strike')).toBeDefined();
|
||||
expect(getByText('Test Task')).toBeDefined();
|
||||
expect(container.querySelector('svg')).toBeDefined(); // Check2Circle icon
|
||||
});
|
||||
|
||||
it('should render the TaskTitle component with high priority and not done', () => {
|
||||
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} done={false} />);
|
||||
it('should render the TaskTitle component with high priority and not completed', () => {
|
||||
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} completed={false} />);
|
||||
expect(container.querySelector('.task-title-icon')).toBeDefined();
|
||||
expect(container.querySelector('.text-strike')).toBeNull();
|
||||
expect(getByText('Test Task')).toBeDefined();
|
||||
expect(container.querySelector('svg')).toBeDefined(); // Bell icon
|
||||
});
|
||||
|
||||
it('should render the TaskTitle component with not high priority and done', () => {
|
||||
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} done={true} />);
|
||||
it('should render the TaskTitle component with not high priority and completed', () => {
|
||||
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} completed={true} />);
|
||||
expect(container.querySelector('.task-title-icon')).toBeDefined();
|
||||
expect(container.querySelector('.text-strike')).toBeDefined();
|
||||
expect(getByText('Test Task')).toBeDefined();
|
||||
expect(container.querySelector('svg')).toBeDefined(); // Check2Circle icon
|
||||
});
|
||||
|
||||
it('should render the TaskTitle component with not high priority and not done', () => {
|
||||
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} done={false} />);
|
||||
it('should render the TaskTitle component with not high priority and not completed', () => {
|
||||
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} completed={false} />);
|
||||
expect(container.querySelector('.task-title-icon')).toBeDefined();
|
||||
expect(container.querySelector('.text-strike')).toBeNull();
|
||||
expect(getByText('Test Task')).toBeDefined();
|
||||
|
||||
@@ -28,6 +28,8 @@ describe('Portuguese Utils unit tests', () => {
|
||||
expect(translateTimeMessage('1 month left', 'pt_br')).toBe('1 mês restante');
|
||||
expect(translateTimeMessage('2 days left', 'pt_br')).toBe('2 dias restantes');
|
||||
expect(translateTimeMessage('1 day left', 'pt_br')).toBe('1 dia restante');
|
||||
expect(translateTimeMessage('Due tomorrow', 'pt_br')).toBe('Vence amanhã');
|
||||
expect(translateTimeMessage('Due today', 'pt_br')).toBe('Vence hoje');
|
||||
expect(translateTimeMessage('lala', 'pt_br')).toBe('lala');
|
||||
expect(translateTimeMessage('null', 'pt_br')).toBe('null');
|
||||
});
|
||||
|
||||
@@ -49,6 +49,8 @@ describe('Russian Utils unit tests', () => {
|
||||
expect(translateTimeMessage('7 days left', 'ru')).toBe('осталось 7 дней');
|
||||
expect(translateTimeMessage('8 days left', 'ru')).toBe('осталось 8 дней');
|
||||
expect(translateTimeMessage('9 days left', 'ru')).toBe('осталось 9 дней');
|
||||
expect(translateTimeMessage('Due tomorrow', 'ru')).toBe('Срок завтра');
|
||||
expect(translateTimeMessage('Due today', 'ru')).toBe('Срок сегодня');
|
||||
expect(translateTimeMessage('lala', 'ru')).toBe('lala');
|
||||
expect(translateTimeMessage('null', 'ru')).toBe('null');
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ describe('Spanish Utils unit tests', () => {
|
||||
expect(translateTimeMessage('1 month left', 'es')).toBe('Falta 1 mes');
|
||||
expect(translateTimeMessage('2 days left', 'es')).toBe('Faltan 2 días');
|
||||
expect(translateTimeMessage('1 day left', 'es')).toBe('Falta 1 día');
|
||||
expect(translateTimeMessage('Due tomorrow', 'es')).toBe('Vence mañana');
|
||||
expect(translateTimeMessage('Due today', 'es')).toBe('Vence hoy');
|
||||
expect(translateTimeMessage('lala', 'es')).toBe('lala');
|
||||
expect(translateTimeMessage('null', 'es')).toBe('null');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const tasks: TaskResponse[] = [
|
||||
{
|
||||
id: 1,
|
||||
description: 'description',
|
||||
done: false,
|
||||
completed: false,
|
||||
highPriority: true,
|
||||
dueDate: '',
|
||||
dueDateFmt: '',
|
||||
|
||||
@@ -22,7 +22,11 @@ vi.mock('react-i18next', () => ({
|
||||
vi.mock('../../api-service/api', () => ({
|
||||
default: {
|
||||
getJSON: vi.fn(),
|
||||
deleteNoContent: vi.fn()
|
||||
postJSON: vi.fn(),
|
||||
patchJSON: vi.fn(),
|
||||
putJSON: vi.fn(),
|
||||
deleteNoContent: vi.fn(),
|
||||
getJSONNoAuth: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -42,7 +46,9 @@ vi.mock('react-router', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('react-bootstrap-icons', () => ({
|
||||
ThreeDotsVertical: () => <div data-testid="three-dots-icon">•••</div>
|
||||
ThreeDotsVertical: () => <div data-testid="three-dots-icon">•••</div>,
|
||||
CheckSquare: () => <div data-testid="task-icon">☑</div>,
|
||||
JournalText: () => <div data-testid="note-icon">📝</div>
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
@@ -55,7 +61,19 @@ vi.mock('../../components/AlertError', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ModalMarkdown', () => ({
|
||||
default: (props: any) => <div data-testid="modal-markdown">{props.show ? 'Modal Open' : ''}</div>
|
||||
default: (props: any) => (
|
||||
<div data-testid="modal-markdown">
|
||||
{props.show ? (
|
||||
<div>
|
||||
<div data-testid="modal-title">{props.title}</div>
|
||||
<div data-testid="modal-content">{props.markdownText}</div>
|
||||
<button data-testid="modal-close" onClick={props.onHide}>Close</button>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('../../components/TaskTitle', () => ({
|
||||
@@ -67,7 +85,14 @@ vi.mock('../../components/TaskTimeLeft', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../components/TaskTag', () => ({
|
||||
default: (props: any) => <div data-testid="task-tag">{props.tag}</div>
|
||||
default: (props: any) => (
|
||||
<div data-testid="task-tag">
|
||||
{props.tag}
|
||||
{props.taskOrNote === 'note' && props.onClick && (
|
||||
<a href="#" data-testid="open-it" onClick={props.onClick}>Open it</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('../../components/NoteTitle', () => ({
|
||||
@@ -79,7 +104,7 @@ const mockTasks: TaskResponse[] = [
|
||||
{
|
||||
id: 1,
|
||||
description: 'Task 1',
|
||||
done: false,
|
||||
completed: false,
|
||||
urls: ['http://example.com'],
|
||||
tags: ['work'],
|
||||
lastUpdate: '2023-10-10',
|
||||
@@ -90,7 +115,7 @@ const mockTasks: TaskResponse[] = [
|
||||
{
|
||||
id: 2,
|
||||
description: 'Task 2',
|
||||
done: true,
|
||||
completed: true,
|
||||
urls: [],
|
||||
tags: ['home'],
|
||||
lastUpdate: '2023-10-09',
|
||||
@@ -109,7 +134,8 @@ const mockNotes: NoteResponse[] = [
|
||||
lastUpdate: '2023-10-10',
|
||||
url: 'http://example.com',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
shareToken: null,
|
||||
archived: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -119,7 +145,8 @@ const mockNotes: NoteResponse[] = [
|
||||
lastUpdate: '2023-10-09',
|
||||
url: null,
|
||||
shared: false,
|
||||
shareToken: null
|
||||
shareToken: null,
|
||||
archived: false
|
||||
}
|
||||
];
|
||||
|
||||
@@ -170,6 +197,7 @@ describe('Home Component', () => {
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
(api.deleteNoContent as any).mockResolvedValue(undefined);
|
||||
(api.putJSON as any).mockResolvedValue(undefined);
|
||||
|
||||
// Mock window.innerWidth for the cleanText function
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -198,6 +226,8 @@ describe('Home Component', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('task-title').length).toBe(2);
|
||||
expect(screen.getAllByTestId('note-title').length).toBe(2);
|
||||
expect(screen.getAllByTestId('task-icon').length).toBe(2);
|
||||
expect(screen.getAllByTestId('note-icon').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,14 +362,19 @@ describe('Home Component', () => {
|
||||
fireEvent.click(markAsDoneButton!);
|
||||
});
|
||||
|
||||
// Should call deleteNoContent API
|
||||
expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/1'));
|
||||
// Should call patchJSON API with completed: true
|
||||
expect(api.patchJSON).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/1'),
|
||||
expect.objectContaining({ completed: true })
|
||||
);
|
||||
|
||||
// Should reload tasks
|
||||
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('tasks'));
|
||||
});
|
||||
|
||||
test('deletes note', async () => {
|
||||
test('archives note', async () => {
|
||||
(api.putJSON as any).mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
@@ -352,25 +387,24 @@ describe('Home Component', () => {
|
||||
const noteDropdownToggles = screen.getAllByTestId('three-dots-icon');
|
||||
// Note dropdowns start after task dropdowns
|
||||
const firstNoteDropdown = noteDropdownToggles[mockTasks.length];
|
||||
|
||||
|
||||
// Click the dropdown toggle
|
||||
await act(async () => {
|
||||
fireEvent.click(firstNoteDropdown);
|
||||
});
|
||||
|
||||
// Find and click the "Delete" option by testId
|
||||
const deleteButtons = screen.getAllByRole('button');
|
||||
const deleteButton = deleteButtons.find(
|
||||
button => button.textContent === 'task_table_action_delete'
|
||||
);
|
||||
// Find and click the "Archive" option by testId
|
||||
const archiveButton = screen.getByTestId('note-dropdown-archive-item-1');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton!);
|
||||
fireEvent.click(archiveButton);
|
||||
});
|
||||
|
||||
// Should call archive API immediately
|
||||
await waitFor(() => {
|
||||
expect(api.putJSON).toHaveBeenCalledWith(expect.stringContaining('/notes/1/archive'), {});
|
||||
});
|
||||
|
||||
// Should call deleteNoContent API
|
||||
expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/2'));
|
||||
|
||||
// Should reload notes
|
||||
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('notes'));
|
||||
});
|
||||
@@ -459,7 +493,7 @@ describe('Home Component', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps filter selection after deleting a note', async () => {
|
||||
test('keeps filter selection after archiving a note', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
@@ -491,11 +525,11 @@ describe('Home Component', () => {
|
||||
});
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button');
|
||||
const deleteButton = deleteButtons.find(
|
||||
button => button.textContent === 'task_table_action_delete'
|
||||
const archiveButton = deleteButtons.find(
|
||||
button => button.textContent === 'note_action_archive'
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton!);
|
||||
fireEvent.click(archiveButton!);
|
||||
});
|
||||
|
||||
// After reload, filter should still be applied - tasks should remain hidden
|
||||
@@ -545,6 +579,79 @@ describe('Home Component', () => {
|
||||
expect(screen.getAllByTestId('task-title')[0].textContent).toBe('Task 1');
|
||||
});
|
||||
});
|
||||
|
||||
test('saves note ID to localStorage when opening modal', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('open-it').length).toBe(2);
|
||||
});
|
||||
|
||||
const openItLinks = screen.getAllByTestId('open-it');
|
||||
await act(async () => {
|
||||
fireEvent.click(openItLinks[1]);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('OPEN_NOTE_ID')).toBe('1');
|
||||
expect(screen.getByTestId('modal-title').textContent).toBe('Note 1');
|
||||
expect(screen.getByTestId('modal-content').textContent).toBe('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
|
||||
test('restores open note modal from localStorage on reload', async () => {
|
||||
localStorage.setItem('OPEN_NOTE_ID', '2');
|
||||
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-title').textContent).toBe('Note 2');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('modal-content').textContent).toBe('This is a sample\nnote content');
|
||||
});
|
||||
|
||||
test('does not restore modal if localStorage note ID not found', async () => {
|
||||
localStorage.setItem('OPEN_NOTE_ID', '999');
|
||||
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('note-title').length).toBe(2);
|
||||
});
|
||||
|
||||
const modal = screen.getByTestId('modal-markdown');
|
||||
expect(modal.textContent).toBe('');
|
||||
expect(localStorage.getItem('OPEN_NOTE_ID')).toBeNull();
|
||||
});
|
||||
|
||||
test('clears localStorage when closing modal', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('open-it').length).toBe(2);
|
||||
});
|
||||
|
||||
const openItLinks = screen.getAllByTestId('open-it');
|
||||
await act(async () => {
|
||||
fireEvent.click(openItLinks[1]);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('OPEN_NOTE_ID')).toBe('1');
|
||||
|
||||
const closeButton = screen.getByTestId('modal-close');
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('OPEN_NOTE_ID')).toBeNull();
|
||||
});
|
||||
/*
|
||||
test('getFirstRows properly formats note preview', async () => {
|
||||
await act(async () => {
|
||||
|
||||
@@ -155,7 +155,8 @@ describe('NoteAdd Component', () => {
|
||||
tags: [],
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
shareToken: null,
|
||||
archived: false
|
||||
}
|
||||
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@import '../../styles/theme.scss';
|
||||
|
||||
.form-control[type="date"] {
|
||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||
border-left: none;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
@import '../../styles/theme.scss';
|
||||
|
||||
/* custom-datepicker.scss */
|
||||
.react-datepicker-wrapper,
|
||||
.react-datepicker__input-container {
|
||||
flex: 1 1 auto !important;
|
||||
width: 1% !important;
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.react-datepicker__input-container {
|
||||
/* Ensure it takes full width of the flex item */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.react-datepicker__input-container input {
|
||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day {
|
||||
margin: 0.2rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Larger touch targets on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.react-datepicker__day,
|
||||
.react-datepicker__month-text,
|
||||
.react-datepicker__quarter-text,
|
||||
.react-datepicker__year-text {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
line-height: 2.5rem;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Col, Form, InputGroup, Row } from 'react-bootstrap';
|
||||
import * as Icons from 'react-bootstrap-icons';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import { MiddlewareReturn } from '@floating-ui/core';
|
||||
import { MiddlewareState } from '@floating-ui/dom';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import './custom-datepicker.scss';
|
||||
import './FormInput.scss';
|
||||
|
||||
type IconName = keyof typeof Icons;
|
||||
|
||||
@@ -17,9 +13,7 @@ interface Props {
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
valueDate?: Date | null;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onChangeDate?: (date: Date | null) => void;
|
||||
dataTestId?: string;
|
||||
pwdHideText?: string;
|
||||
pwdShowText?: string;
|
||||
@@ -67,36 +61,16 @@ function FormInput(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
<InputGroup.Text>
|
||||
<Icon />
|
||||
</InputGroup.Text>
|
||||
{props.type == 'date'
|
||||
{props.type === 'date'
|
||||
? (
|
||||
<DatePicker
|
||||
selected={props?.valueDate}
|
||||
onChange={(date: Date | null) => {
|
||||
if (props.onChangeDate) {
|
||||
props.onChangeDate(date);
|
||||
}
|
||||
}}
|
||||
dateFormat="MMMM d, yyyy"
|
||||
className="form-control"
|
||||
id="date-input"
|
||||
placeholderText={props.placeholder}
|
||||
popperPlacement="bottom"
|
||||
popperModifiers={[
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
enabled: true,
|
||||
boundariesElement: 'viewport'
|
||||
},
|
||||
fn: function (state: MiddlewareState): MiddlewareReturn | Promise<MiddlewareReturn> {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
]}
|
||||
withPortal
|
||||
showYearDropdown
|
||||
showMonthDropdown
|
||||
dropdownMode="select"
|
||||
<Form.Control
|
||||
required={props.required}
|
||||
type="date"
|
||||
name={props.name}
|
||||
placeholder={props.placeholder ? props.placeholder : ''}
|
||||
value={props?.value}
|
||||
onChange={props.onChange}
|
||||
data-testid={props.dataTestId}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -10,6 +9,8 @@ type Props = {
|
||||
title: string;
|
||||
markdownText: string;
|
||||
onHide: () => void;
|
||||
onSave?: () => Promise<boolean>;
|
||||
saveButtonLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -73,23 +74,42 @@ const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
|
||||
<Button variant="outline-secondary" onClick={handleHide}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHide}
|
||||
className="home-new-item-secondary task-note-btn"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant={showSource ? 'info' : 'outline-info'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleSource}
|
||||
data-testid="modal-source-button"
|
||||
className={`${showSource ? 'home-new-item' : 'home-new-item-secondary'} task-note-btn`}
|
||||
>
|
||||
Source
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
data-testid="modal-copy-button"
|
||||
className="home-new-item-secondary task-note-btn"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</button>
|
||||
{props.onSave && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
handleHide();
|
||||
await props.onSave!();
|
||||
}}
|
||||
data-testid="modal-save-button"
|
||||
className="home-new-item task-note-btn"
|
||||
>
|
||||
{props.saveButtonLabel ?? 'Save note'}
|
||||
</button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
const [lastSeen, setLastSeen] = useState('');
|
||||
const { t } = useTranslation();
|
||||
const build = `Build: ${env.VITE_BUILD}`;
|
||||
const changeLogUrl = 'https://lightroasted.vps-kinghost.net/rmcampos/tasknote/src/branch/main/CHANGELOG.md';
|
||||
|
||||
// Note: when selected, change class to plus-jakarta-sans-thin and add background
|
||||
|
||||
@@ -119,7 +120,15 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
<small>{t('sidebar_last_seen', { time: lastSeen })}</small>
|
||||
</div>
|
||||
)}
|
||||
<small data-testid="footer-text">{build}</small>
|
||||
<a
|
||||
data-testid="footer-text"
|
||||
href={changeLogUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer-link"
|
||||
>
|
||||
<small>{build}</small>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -96,3 +96,12 @@ a.active {
|
||||
border-left: #4CD964 0.3rem solid;
|
||||
background: #ced6da linear-gradient(270deg, rgba(53, 99, 233, 0.36) -416.06%, rgba(53, 99, 233, 0) 94.8%);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function TaskTag(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col className="d-inline-block text-muted card-tag poppins-regular">
|
||||
<Col className="d-inline-block card-tag poppins-regular">
|
||||
{tagContent}
|
||||
{' '}
|
||||
{props.taskOrNote}
|
||||
@@ -40,7 +40,7 @@ function TaskTag(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
<Col className="d-inline-block text-muted card-tag ms-5 text-end poppins-regular">
|
||||
<Col className="d-inline-block card-tag ms-5 text-end poppins-regular">
|
||||
{props.lastUpdate}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.card-tag {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--bs-body-color-rgb), 0.55);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ import { CalendarCheck } from 'react-bootstrap-icons';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
done: boolean;
|
||||
completed: boolean;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the TaskTimeLeft component if the task is not done, displaying
|
||||
* Renders the TaskTimeLeft component if the task is not completed, displaying
|
||||
* a calendar icon and the time left for the task.
|
||||
*
|
||||
* @param {Props} props - Props for the TaskTimeLeft component.
|
||||
* @param {string} props.text - The time left for the task.
|
||||
* @param {boolean} props.done - Boolean value indicating if the task is done.
|
||||
* @param {boolean} props.completed - Boolean value indicating if the task is completed.
|
||||
* @param {string} props.tooltip - The string representation of a Date instance.
|
||||
* @returns
|
||||
*/
|
||||
@@ -27,7 +27,7 @@ function TaskTimeLeft(props: React.PropsWithChildren<Props>): React.ReactNode |
|
||||
}).format(new Date(props.tooltip))
|
||||
: '';
|
||||
|
||||
return props.done
|
||||
return props.completed
|
||||
? null
|
||||
: (
|
||||
<div className="d-block task-due-date">
|
||||
|
||||
@@ -5,7 +5,7 @@ import './style.css';
|
||||
|
||||
interface Props {
|
||||
readonly title: string;
|
||||
readonly done: boolean;
|
||||
readonly completed: boolean;
|
||||
readonly taskUrl: string[];
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ interface Props {
|
||||
*
|
||||
* @param {Props} props - The props for the component.
|
||||
* @param {string} [props.title] - The title for the task.
|
||||
* @param {boolean} [props.done] - Define if the task is completed.
|
||||
* @param {boolean} [props.completed] - Define if the task is completed.
|
||||
* @returns {React.ReactNode} The rendered TaskTitle component.
|
||||
*/
|
||||
function TaskTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
return (
|
||||
<span className="task-title-icon" data-testid={`task-title-container-${props.title}`}>
|
||||
<span
|
||||
className={`${props.done ? 'ms-2 text-strike' : ''} poppins-semibold`}
|
||||
className={`${props.completed ? 'ms-2 text-strike' : ''} poppins-semibold`}
|
||||
data-testid={`task-title-text-${props.title}`}
|
||||
>
|
||||
{props.title}
|
||||
|
||||
@@ -76,6 +76,8 @@ const enTranslations = {
|
||||
home_card_task_pending: 'Pending tasks',
|
||||
home_card_task_empty: 'No pending tasks',
|
||||
home_card_task_done: 'done tasks!',
|
||||
home_completed_tasks_title: 'Completed tasks',
|
||||
home_archived_notes_title: 'Archived notes',
|
||||
home_card_task_done_empty: 'No done tasks!',
|
||||
home_card_task_btn: 'Go to Tasks',
|
||||
home_card_note_title: 'Notes Summary',
|
||||
@@ -105,6 +107,10 @@ const enTranslations = {
|
||||
task_table_action_edit: 'Edit',
|
||||
task_table_action_clone: 'Clone',
|
||||
task_table_action_delete: 'Delete',
|
||||
delete_modal_title: 'Confirm deletion',
|
||||
delete_modal_body: 'Are you sure you want to delete this item? This action cannot be undone.',
|
||||
delete_modal_cancel: 'Cancel',
|
||||
delete_modal_confirm: 'Delete',
|
||||
|
||||
note_form_title: 'Add note',
|
||||
note_form_title_label: 'Title',
|
||||
@@ -117,6 +123,9 @@ const enTranslations = {
|
||||
note_action_share: 'Share',
|
||||
note_action_unshare: 'Unshare',
|
||||
note_action_copy_link: 'Copy link',
|
||||
note_action_archive: 'Archive',
|
||||
note_action_restore: 'Restore',
|
||||
note_action_delete_permanently: 'Delete permanently',
|
||||
|
||||
about_page_title_one: 'About the',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const timeAgoTranslations: Record<string, string> = {
|
||||
'month left_pt_br': '{X} mês restante',
|
||||
'days left_pt_br': '{X} dias restantes',
|
||||
'day left_pt_br': '{X} dia restante',
|
||||
'due tomorrow_pt_br': 'Vence amanhã',
|
||||
'due today_pt_br': 'Vence hoje',
|
||||
|
||||
'years ago_es': 'Hace {X} años',
|
||||
'year ago_es': 'Hace {X} año',
|
||||
@@ -40,6 +42,8 @@ export const timeAgoTranslations: Record<string, string> = {
|
||||
'month left_es': 'Falta {X} mes',
|
||||
'days left_es': 'Faltan {X} días',
|
||||
'day left_es': 'Falta {X} día',
|
||||
'due tomorrow_es': 'Vence mañana',
|
||||
'due today_es': 'Vence hoy',
|
||||
|
||||
'years ago_ru': '{X} года назад',
|
||||
'year ago_ru': '{X} год назад',
|
||||
@@ -60,7 +64,9 @@ export const timeAgoTranslations: Record<string, string> = {
|
||||
'months left_ru': 'осталось {X} месяца',
|
||||
'month left_ru': 'Остался {X} месяц',
|
||||
'days left_ru': 'осталось {X} дня',
|
||||
'day left_ru': 'Остался {X} день'
|
||||
'day left_ru': 'Остался {X} день',
|
||||
'due tomorrow_ru': 'Срок завтра',
|
||||
'due today_ru': 'Срок сегодня'
|
||||
};
|
||||
|
||||
export const serverResponsesTranslations: Record<string, string> = {
|
||||
|
||||
@@ -76,6 +76,8 @@ const ptBrTranslations = {
|
||||
home_card_task_pending: 'Tarefa(s) pendente(s)',
|
||||
home_card_task_empty: 'Nenhuma tarefa pendente',
|
||||
home_card_task_done: 'tarefa(s) concluída(s)',
|
||||
home_completed_tasks_title: 'Tarefas concluídas',
|
||||
home_archived_notes_title: 'Notas arquivadas',
|
||||
home_card_task_done_empty: 'Nenhuma tarefa condluída',
|
||||
home_card_task_btn: 'Ir para Tarefas',
|
||||
home_card_note_title: 'Resumo de Notas',
|
||||
@@ -105,6 +107,10 @@ const ptBrTranslations = {
|
||||
task_table_action_edit: 'Alterar',
|
||||
task_table_action_clone: 'Clonar',
|
||||
task_table_action_delete: 'Excluir',
|
||||
delete_modal_title: 'Confirmar exclusão',
|
||||
delete_modal_body: 'Tem certeza de que deseja excluir este item? Esta ação não pode ser desfeita.',
|
||||
delete_modal_cancel: 'Cancelar',
|
||||
delete_modal_confirm: 'Excluir',
|
||||
|
||||
note_form_title: 'Adicionar nota',
|
||||
note_form_title_label: 'Título',
|
||||
@@ -117,6 +123,9 @@ const ptBrTranslations = {
|
||||
note_action_share: 'Compartilhar',
|
||||
note_action_unshare: 'Parar de compartilhar',
|
||||
note_action_copy_link: 'Copiar link',
|
||||
note_action_archive: 'Arquivar',
|
||||
note_action_restore: 'Restaurar',
|
||||
note_action_delete_permanently: 'Excluir permanentemente',
|
||||
|
||||
about_page_title_one: 'Sobre o',
|
||||
about_page_title_two: 'App TaskNote',
|
||||
|
||||
@@ -76,6 +76,8 @@ const ruTranslations = {
|
||||
home_card_task_pending: 'Незавершённые задачи',
|
||||
home_card_task_empty: 'Нет незавершённых задач',
|
||||
home_card_task_done: 'выполненные задачи!',
|
||||
home_completed_tasks_title: 'Выполненные задачи',
|
||||
home_archived_notes_title: 'Архивированные заметки',
|
||||
home_card_task_done_empty: 'Нет выполненных задач!',
|
||||
home_card_task_btn: 'Перейти к задачам',
|
||||
home_card_note_title: 'Обзор заметок',
|
||||
@@ -105,6 +107,10 @@ const ruTranslations = {
|
||||
task_table_action_edit: 'Редактировать',
|
||||
task_table_action_clone: 'Клонировать',
|
||||
task_table_action_delete: 'Удалить',
|
||||
delete_modal_title: 'Подтвердите удаление',
|
||||
delete_modal_body: 'Вы уверены, что хотите удалить этот элемент? Это действие не может быть отменено.',
|
||||
delete_modal_cancel: 'Отмена',
|
||||
delete_modal_confirm: 'Удалить',
|
||||
|
||||
note_form_title: 'Добавить примечание',
|
||||
note_form_title_label: 'Заголовок',
|
||||
@@ -117,6 +123,9 @@ const ruTranslations = {
|
||||
note_action_share: 'Поделиться',
|
||||
note_action_unshare: 'Закрыть доступ',
|
||||
note_action_copy_link: 'Копировать ссылку',
|
||||
note_action_archive: 'Архивировать',
|
||||
note_action_restore: 'Восстановить',
|
||||
note_action_delete_permanently: 'Удалить навсегда',
|
||||
|
||||
about_page_title_one: 'около',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -76,6 +76,8 @@ const esTranslations = {
|
||||
home_card_task_pending: 'Tarea(s) pendiente(s)',
|
||||
home_card_task_empty: 'No tienes tareas pendientes',
|
||||
home_card_task_done: 'tarea(s) completada(s)',
|
||||
home_completed_tasks_title: 'Tareas completadas',
|
||||
home_archived_notes_title: 'Notas archivadas',
|
||||
home_card_task_done_empty: 'No tareas completadas',
|
||||
home_card_task_btn: 'Ir a Tareas',
|
||||
home_card_note_title: 'Resumen de Notas',
|
||||
@@ -105,6 +107,10 @@ const esTranslations = {
|
||||
task_table_action_edit: 'Editar',
|
||||
task_table_action_clone: 'Clonar',
|
||||
task_table_action_delete: 'Eliminar',
|
||||
delete_modal_title: 'Confirmar eliminación',
|
||||
delete_modal_body: '¿Estás seguro de que deseas eliminar este elemento? Esta acción no se puede deshacer.',
|
||||
delete_modal_cancel: 'Cancelar',
|
||||
delete_modal_confirm: 'Eliminar',
|
||||
|
||||
note_form_title: 'Añadir nota',
|
||||
note_form_title_label: 'Título',
|
||||
@@ -117,6 +123,9 @@ const esTranslations = {
|
||||
note_action_share: 'Compartir',
|
||||
note_action_unshare: 'Dejar de compartir',
|
||||
note_action_copy_link: 'Copiar enlace',
|
||||
note_action_archive: 'Archivar',
|
||||
note_action_restore: 'Restaurar',
|
||||
note_action_delete_permanently: 'Eliminar permanentemente',
|
||||
|
||||
about_page_title_one: 'Acerca de',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -127,6 +127,19 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!signed) return;
|
||||
const TWENTY_FIVE_MINUTES = 25 * 60 * 1000;
|
||||
const intervalId = setInterval(() => {
|
||||
checkCurrentAuthUser(window.location.pathname).catch(() => {
|
||||
setSigned(false);
|
||||
setUser(undefined);
|
||||
localStorage.clear();
|
||||
});
|
||||
}, TWENTY_FIVE_MINUTES);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [signed]);
|
||||
|
||||
const updateUser = (userUpdated: UserResponse): void => {
|
||||
setUser(userUpdated);
|
||||
localStorage.setItem(USER_DATA, JSON.stringify(userUpdated));
|
||||
|
||||
@@ -241,6 +241,27 @@ a:hover, .btn-link:hover {
|
||||
background-color: #333b42;
|
||||
}
|
||||
|
||||
.home-new-item-danger {
|
||||
border: 1px solid #FB1A41;
|
||||
border-radius: 3px;
|
||||
padding: 8px 16px;
|
||||
color: #fff;
|
||||
background-color: #FB1A41;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.home-new-item-danger:hover {
|
||||
background-color: #d91638;
|
||||
}
|
||||
|
||||
.home-item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.task-note-btn {
|
||||
height: 48px;
|
||||
}
|
||||
@@ -277,8 +298,7 @@ code {
|
||||
padding: 0.375rem 0.10rem 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.input-group > .form-control,
|
||||
.react-datepicker__input-container > .form-control {
|
||||
.input-group > .form-control {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
@@ -379,7 +399,14 @@ p.search-result-item-title {
|
||||
|
||||
.task-card {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.task-completed {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.task-completed .card-title {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
|
||||
@@ -7,6 +7,7 @@ type NoteResponse = {
|
||||
lastUpdate: string;
|
||||
shared: boolean;
|
||||
shareToken: string | null;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
export type { NoteResponse };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
type TaskResponse = {
|
||||
id: number;
|
||||
description: string;
|
||||
done: boolean;
|
||||
completed: boolean;
|
||||
highPriority: boolean;
|
||||
dueDate: string;
|
||||
dueDateFmt: string;
|
||||
|
||||
@@ -15,6 +15,14 @@ function translateTimeMessage(message: string, target: string): string {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (message === 'Due tomorrow') {
|
||||
return timeAgoTranslations[`due tomorrow_${target}`] ?? message;
|
||||
}
|
||||
|
||||
if (message === 'Due today') {
|
||||
return timeAgoTranslations[`due today_${target}`] ?? message;
|
||||
}
|
||||
|
||||
const firstSpace = message.indexOf(' ');
|
||||
const numberValue = message.substring(0, firstSpace);
|
||||
const textValue = message.substring(firstSpace).trim();
|
||||
|
||||
@@ -286,22 +286,29 @@ function Account(): React.ReactNode {
|
||||
</span>
|
||||
|
||||
<p className="mt-4 mb-2">{t('account_privacy_text')}</p>
|
||||
<Button
|
||||
variant="danger"
|
||||
type="button"
|
||||
onClick={() => setShowAlert(true)}
|
||||
className=""
|
||||
>
|
||||
{t('account_privacy_delete_btn')}
|
||||
</Button>
|
||||
<div className="d-grid">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAlert(true)}
|
||||
className="home-new-item-danger task-note-btn"
|
||||
>
|
||||
{t('account_privacy_delete_btn')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAlert && (
|
||||
<Alert className="mt-3" variant="danger" onClose={() => setShowAlert(false)} dismissible>
|
||||
<Alert.Heading>{t('account_delete_title')}</Alert.Heading>
|
||||
<p>{t('account_delete_description')}</p>
|
||||
<Button onClick={() => deleteAccount()} variant="outline-danger">
|
||||
{t('account_delete_btn')}
|
||||
</Button>
|
||||
<div className="d-grid">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteAccount()}
|
||||
className="home-new-item-danger task-note-btn"
|
||||
>
|
||||
{t('account_delete_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</Card.Body>
|
||||
|
||||
+407
-31
@@ -2,12 +2,14 @@ import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Dropdown,
|
||||
Form,
|
||||
InputGroup,
|
||||
Modal,
|
||||
Row
|
||||
} from 'react-bootstrap';
|
||||
import { TaskResponse } from '../../types/TaskResponse';
|
||||
@@ -20,7 +22,7 @@ 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';
|
||||
import { CheckSquare, JournalText, ThreeDotsVertical } from 'react-bootstrap-icons';
|
||||
import { NavLink } from 'react-router';
|
||||
import ModalMarkdown from '../../components/ModalMarkdown';
|
||||
import TaskTitle from '../../components/TaskTitle';
|
||||
@@ -28,6 +30,8 @@ import TaskTimeLeft from '../../components/TaskTimeLeft';
|
||||
import TaskTag from '../../components/TaskTag';
|
||||
import NoteTitle from '../../components/NoteTitle';
|
||||
|
||||
const OPEN_NOTE_ID_KEY = 'OPEN_NOTE_ID';
|
||||
|
||||
/**
|
||||
* Home page component.
|
||||
*
|
||||
@@ -46,9 +50,13 @@ function Home(): React.ReactNode {
|
||||
const [modalTitle, setModalTitle] = useState<string>('');
|
||||
const [modalContent, setModalContent] = useState<string>('');
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>([]);
|
||||
const [completedTasks, setCompletedTasks] = useState<TaskResponse[]>([]);
|
||||
const [notes, setNotes] = useState<NoteResponse[]>([]);
|
||||
const [archivedNotes, setArchivedNotes] = useState<NoteResponse[]>([]);
|
||||
const [savedNotes, setSavedNotes] = useState<NoteResponse[]>([]);
|
||||
const [savedTasks, setSavedTasks] = useState<TaskResponse[]>([]);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ type: 'task' | 'note'; id: number } | null>(null);
|
||||
|
||||
/**
|
||||
* Handles the error by setting the error message.
|
||||
@@ -75,13 +83,28 @@ function Home(): React.ReactNode {
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark a task as done or undone.
|
||||
* Toggle a task's completed status.
|
||||
*
|
||||
* @param {TaskResponse} task The task to be marked as done or undone.
|
||||
* @param {TaskResponse} task The task to be marked as completed or uncompleted.
|
||||
*/
|
||||
const markAsDone = async (task: TaskResponse): Promise<void> => {
|
||||
const toggleTaskCompleted = async (task: TaskResponse): Promise<void> => {
|
||||
try {
|
||||
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${task.id}`);
|
||||
await api.patchJSON(`${ApiConfig.tasksUrl}/${task.id}`, { completed: !task.completed });
|
||||
await loadAllTasks();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a task.
|
||||
*
|
||||
* @param {number} taskIdParam The task ID to be deleted.
|
||||
*/
|
||||
const deleteTask = async (taskIdParam: number) => {
|
||||
try {
|
||||
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskIdParam}`);
|
||||
await loadAllTasks();
|
||||
}
|
||||
catch (e) {
|
||||
@@ -104,6 +127,90 @@ function Home(): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter notes into active and archived sets.
|
||||
*
|
||||
* @param {NoteResponse[]} allNotes The full list of notes to partition.
|
||||
* @returns {{ active: NoteResponse[]; archived: NoteResponse[] }} Active and archived notes.
|
||||
*/
|
||||
const partitionNotes = (allNotes: NoteResponse[]): { active: NoteResponse[]; archived: NoteResponse[] } => {
|
||||
return allNotes.reduce<{ active: NoteResponse[]; archived: NoteResponse[] }>(
|
||||
(acc, note) => {
|
||||
if (note.archived) {
|
||||
acc.archived.push(note);
|
||||
}
|
||||
else {
|
||||
acc.active.push(note);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ active: [], archived: [] }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the delete confirmation modal for a task or note.
|
||||
*
|
||||
* @param {object} target The target to delete with type and id.
|
||||
*/
|
||||
const confirmDelete = (target: { type: 'task' | 'note'; id: number }) => {
|
||||
setDeleteTarget(target);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirms and executes the delete action.
|
||||
*/
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteTarget.type === 'task') {
|
||||
await deleteTask(deleteTarget.id);
|
||||
}
|
||||
else {
|
||||
await deleteNote(deleteTarget.id);
|
||||
}
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive a note, moving it to the archived notes section.
|
||||
*
|
||||
* @param {number} noteId The note ID to be archived.
|
||||
*/
|
||||
const archiveNote = async (noteId: number): Promise<void> => {
|
||||
try {
|
||||
await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/archive`, {});
|
||||
await loadAllNotes();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore an archived note back to active notes.
|
||||
*
|
||||
* @param {number} noteId The note ID to be restored.
|
||||
*/
|
||||
const restoreNote = async (noteId: number): Promise<void> => {
|
||||
try {
|
||||
await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/restore`, {});
|
||||
await loadAllNotes();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveNote = (noteId: number): void => {
|
||||
void archiveNote(noteId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Share or unshare a note.
|
||||
*
|
||||
@@ -141,9 +248,15 @@ function Home(): React.ReactNode {
|
||||
* @param {NoteResponse[]} allNotes - The full list of notes to filter from.
|
||||
*/
|
||||
const applyFilter = (text: string, radioFilter: string | undefined, allTasks: TaskResponse[], allNotes: NoteResponse[]): void => {
|
||||
const activeTasks = allTasks.filter((task: TaskResponse) => !task.completed);
|
||||
const doneTasks = allTasks.filter((task: TaskResponse) => task.completed);
|
||||
const { active: activeNotes, archived: archivedNoteList } = partitionNotes(allNotes);
|
||||
|
||||
if (!text && (!radioFilter || radioFilter === 'everything')) {
|
||||
setNotes([...allNotes]);
|
||||
setTasks([...allTasks]);
|
||||
setNotes([...activeNotes]);
|
||||
setArchivedNotes([...archivedNoteList]);
|
||||
setTasks([...activeTasks]);
|
||||
setCompletedTasks([...doneTasks]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -151,9 +264,10 @@ function Home(): React.ReactNode {
|
||||
|
||||
if (radioFilter && radioFilter === 'onlyTasks') {
|
||||
setNotes([]);
|
||||
setArchivedNotes([]);
|
||||
}
|
||||
else {
|
||||
let filteredNotes = allNotes.filter((note: NoteResponse) => {
|
||||
let filteredNotes = activeNotes.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());
|
||||
@@ -168,14 +282,31 @@ function Home(): React.ReactNode {
|
||||
filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
|
||||
}
|
||||
|
||||
let filteredArchivedNotes = archivedNoteList.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());
|
||||
const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()));
|
||||
return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch;
|
||||
});
|
||||
|
||||
if (tagToFilter === 'untagged') {
|
||||
filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0);
|
||||
}
|
||||
else if (tagToFilter) {
|
||||
filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
|
||||
}
|
||||
|
||||
setNotes([...filteredNotes]);
|
||||
setArchivedNotes([...filteredArchivedNotes]);
|
||||
}
|
||||
|
||||
if (radioFilter && radioFilter === 'onlyNotes') {
|
||||
setTasks([]);
|
||||
setCompletedTasks([]);
|
||||
}
|
||||
else {
|
||||
let filteredTasks = allTasks.filter((task: TaskResponse) => {
|
||||
let filteredTasks = activeTasks.filter((task: TaskResponse) => {
|
||||
return task.description.toLowerCase().includes(text.toLowerCase())
|
||||
|| task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()))
|
||||
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
|
||||
@@ -189,6 +320,21 @@ function Home(): React.ReactNode {
|
||||
}
|
||||
|
||||
setTasks([...filteredTasks]);
|
||||
|
||||
let filteredCompletedTasks = doneTasks.filter((task: TaskResponse) => {
|
||||
return task.description.toLowerCase().includes(text.toLowerCase())
|
||||
|| task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()))
|
||||
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
|
||||
});
|
||||
|
||||
if (tagToFilter === 'untagged') {
|
||||
filteredCompletedTasks = filteredCompletedTasks.filter((task: TaskResponse) => !task.tags || task.tags.length === 0);
|
||||
}
|
||||
else if (tagToFilter) {
|
||||
filteredCompletedTasks = filteredCompletedTasks.filter((task: TaskResponse) => task.tags && task.tags.includes(tagToFilter));
|
||||
}
|
||||
|
||||
setCompletedTasks([...filteredCompletedTasks]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,10 +354,16 @@ function Home(): React.ReactNode {
|
||||
const tasksFetched: TaskResponse[] = await api.getJSON(ApiConfig.tasksUrl);
|
||||
const translated = translateTaskResponse(tasksFetched, i18n.language);
|
||||
translated.sort((t1, t2) => {
|
||||
if (t1.highPriority === t2.highPriority) {
|
||||
return 0;
|
||||
if (t1.completed === t2.completed) {
|
||||
if (t1.highPriority === t2.highPriority) {
|
||||
return 0;
|
||||
}
|
||||
if (t1.highPriority) {
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if (t1.highPriority) {
|
||||
if (t1.completed) {
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
@@ -299,7 +451,10 @@ function Home(): React.ReactNode {
|
||||
return preview.join('\n');
|
||||
};
|
||||
|
||||
const handleCloseModal = () => setShowMarkdownView(false);
|
||||
const handleCloseModal = () => {
|
||||
setShowMarkdownView(false);
|
||||
localStorage.removeItem(OPEN_NOTE_ID_KEY);
|
||||
};
|
||||
|
||||
const getSelectedLabel = (): string => {
|
||||
if (selectedOption === 'everything') return t('home_radio_everything');
|
||||
@@ -345,6 +500,22 @@ function Home(): React.ReactNode {
|
||||
applyFilter(filterText, selectedOption, savedTasks, savedNotes);
|
||||
}, [savedTasks, savedNotes, filterText, selectedOption]);
|
||||
|
||||
useEffect(() => {
|
||||
const openNoteId = localStorage.getItem(OPEN_NOTE_ID_KEY);
|
||||
if (openNoteId && notes.length > 0) {
|
||||
const noteId = Number(openNoteId);
|
||||
const foundNote = notes.find(n => n.id === noteId);
|
||||
if (foundNote) {
|
||||
setModalTitle(foundNote.title);
|
||||
setModalContent(foundNote.description);
|
||||
setShowMarkdownView(true);
|
||||
}
|
||||
else {
|
||||
localStorage.removeItem(OPEN_NOTE_ID_KEY);
|
||||
}
|
||||
}
|
||||
}, [notes]);
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<ContentHeader
|
||||
@@ -382,7 +553,9 @@ function Home(): React.ReactNode {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dropdown onSelect={eventKey => eventKey && handleOptionChange(eventKey)}>
|
||||
<Dropdown
|
||||
onSelect={eventKey => eventKey && handleOptionChange(eventKey)}
|
||||
>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
id="filter-dropdown"
|
||||
@@ -405,7 +578,10 @@ function Home(): React.ReactNode {
|
||||
</Badge>
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu className="shadow-lg border-0" style={{ minWidth: '200px' }}>
|
||||
<Dropdown.Menu
|
||||
className="shadow-lg border-0"
|
||||
style={{ minWidth: '200px' }}
|
||||
>
|
||||
<Dropdown.Header className="text-muted small">
|
||||
<i className="bi bi-funnel me-2"></i>
|
||||
Filter Options
|
||||
@@ -472,25 +648,34 @@ function Home(): React.ReactNode {
|
||||
<Row className="mt-3">
|
||||
{tasks.map((task: TaskResponse) => (
|
||||
<Col xs={12} key={task.id.toString()}>
|
||||
<Card key={task.id.toString()} className={`task-card ${task.highPriority ? 'high-importance' : ''}`}>
|
||||
<Card
|
||||
key={task.id.toString()}
|
||||
className={`task-card ${task.highPriority ? 'high-importance' : ''}`}
|
||||
>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
<Card.Title>
|
||||
<span className="home-item-icon">
|
||||
<CheckSquare />
|
||||
</span>
|
||||
<TaskTitle
|
||||
title={task.description}
|
||||
done={task.done}
|
||||
completed={task.completed}
|
||||
taskUrl={task.urls}
|
||||
/>
|
||||
</Card.Title>
|
||||
</Col>
|
||||
<Col xs={2} className="text-end">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="success" data-testid={`task-dropdown-menu-${task.id}`}>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
data-testid={`task-dropdown-menu-${task.id}`}
|
||||
>
|
||||
<ThreeDotsVertical />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{!task.done && (
|
||||
{!task.completed && (
|
||||
<NavLink to={`/tasks/edit/${task.id}`}>
|
||||
<Dropdown.Item as="span">
|
||||
{t('task_table_action_edit')}
|
||||
@@ -499,10 +684,20 @@ function Home(): React.ReactNode {
|
||||
)}
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => markAsDone(task)}
|
||||
onClick={() => toggleTaskCompleted(task)}
|
||||
data-testid={`task-dropdown-done-item-${task.id}`}
|
||||
>
|
||||
{task.done ? t('task_table_action_undone') : t('task_table_action_done')}
|
||||
{task.completed
|
||||
? t('task_table_action_undone')
|
||||
: t('task_table_action_done')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() =>
|
||||
confirmDelete({ type: 'task', id: task.id })}
|
||||
data-testid={`task-dropdown-delete-item-${task.id}`}
|
||||
>
|
||||
{t('task_table_action_delete')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
@@ -512,7 +707,7 @@ function Home(): React.ReactNode {
|
||||
{task.dueDateFmt && (
|
||||
<TaskTimeLeft
|
||||
text={task.dueDateFmt}
|
||||
done={task.done}
|
||||
completed={task.completed}
|
||||
tooltip={task.dueDate}
|
||||
/>
|
||||
)}
|
||||
@@ -537,15 +732,18 @@ function Home(): React.ReactNode {
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
<Card.Title>
|
||||
<NoteTitle
|
||||
title={note.title}
|
||||
noteUrl={note.url}
|
||||
/>
|
||||
<span className="home-item-icon">
|
||||
<JournalText />
|
||||
</span>
|
||||
<NoteTitle title={note.title} noteUrl={note.url} />
|
||||
</Card.Title>
|
||||
</Col>
|
||||
<Col xs={2} className="text-end">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="success" data-testid={`note-dropdown-menu-${note.id}`}>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
data-testid={`note-dropdown-menu-${note.id}`}
|
||||
>
|
||||
<ThreeDotsVertical />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
@@ -564,7 +762,9 @@ function Home(): React.ReactNode {
|
||||
onClick={() => toggleShareNote(note)}
|
||||
data-testid={`note-dropdown-share-item-${note.id}`}
|
||||
>
|
||||
{note.shared ? t('note_action_unshare') : t('note_action_share')}
|
||||
{note.shared
|
||||
? t('note_action_unshare')
|
||||
: t('note_action_share')}
|
||||
</Dropdown.Item>
|
||||
{note.shared && note.shareToken && (
|
||||
<Dropdown.Item
|
||||
@@ -577,10 +777,10 @@ function Home(): React.ReactNode {
|
||||
)}
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => deleteNote(note.id)}
|
||||
data-testid={`note-dropdown-delete-item-${note.id}`}
|
||||
onClick={() => handleArchiveNote(note.id)}
|
||||
data-testid={`note-dropdown-archive-item-${note.id}`}
|
||||
>
|
||||
{t('task_table_action_delete')}
|
||||
{t('note_action_archive')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
@@ -602,6 +802,7 @@ function Home(): React.ReactNode {
|
||||
setModalTitle(note.title);
|
||||
setModalContent(note.description);
|
||||
setShowMarkdownView(true);
|
||||
localStorage.setItem(OPEN_NOTE_ID_KEY, note.id.toString());
|
||||
}}
|
||||
/>
|
||||
</Card.Footer>
|
||||
@@ -610,12 +811,187 @@ function Home(): React.ReactNode {
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{completedTasks.length > 0 && (
|
||||
<Row className="mt-4">
|
||||
<Col xs={12}>
|
||||
<h5 className="text-muted">{t('home_completed_tasks_title')}</h5>
|
||||
</Col>
|
||||
{completedTasks.map((task: TaskResponse) => (
|
||||
<Col xs={12} key={`completed-${task.id.toString()}`}>
|
||||
<Card
|
||||
className={`task-card task-completed ${task.highPriority ? 'high-importance' : ''}`}
|
||||
>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
<Card.Title>
|
||||
<span className="home-item-icon">
|
||||
<CheckSquare />
|
||||
</span>
|
||||
<TaskTitle
|
||||
title={task.description}
|
||||
completed={task.completed}
|
||||
taskUrl={task.urls}
|
||||
/>
|
||||
</Card.Title>
|
||||
</Col>
|
||||
<Col xs={2} className="text-end">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
data-testid={`completed-task-dropdown-menu-${task.id}`}
|
||||
>
|
||||
<ThreeDotsVertical />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => toggleTaskCompleted(task)}
|
||||
data-testid={`completed-task-dropdown-undone-item-${task.id}`}
|
||||
>
|
||||
{t('task_table_action_undone')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() =>
|
||||
confirmDelete({ type: 'task', id: task.id })}
|
||||
data-testid={`completed-task-dropdown-delete-item-${task.id}`}
|
||||
>
|
||||
{t('task_table_action_delete')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
<Card.Footer className="task-card-footer">
|
||||
<TaskTag
|
||||
tags={task.tags}
|
||||
lastUpdate={task.lastUpdate}
|
||||
taskOrNote="task"
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{archivedNotes.length > 0 && (
|
||||
<Row className="mt-4">
|
||||
<Col xs={12}>
|
||||
<h5 className="text-muted">{t('home_archived_notes_title')}</h5>
|
||||
</Col>
|
||||
{archivedNotes.map((note: NoteResponse) => (
|
||||
<Col xs={12} key={`archived-${note.id.toString()}`}>
|
||||
<Card className="task-card task-completed mb-3">
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
<Card.Title>
|
||||
<span className="home-item-icon">
|
||||
<JournalText />
|
||||
</span>
|
||||
<NoteTitle title={note.title} noteUrl={note.url} />
|
||||
</Card.Title>
|
||||
</Col>
|
||||
<Col xs={2} className="text-end">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
data-testid={`archived-note-dropdown-menu-${note.id}`}
|
||||
>
|
||||
<ThreeDotsVertical />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => restoreNote(note.id)}
|
||||
data-testid={`archived-note-dropdown-restore-item-${note.id}`}
|
||||
>
|
||||
{t('note_action_restore')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() =>
|
||||
confirmDelete({ type: 'note', id: note.id })}
|
||||
data-testid={`archived-note-dropdown-delete-item-${note.id}`}
|
||||
>
|
||||
{t('note_action_delete_permanently')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<span className="text-muted span-line-break font-size-14">
|
||||
{getFirstRows(note.description)}
|
||||
</span>
|
||||
</Card.Body>
|
||||
<Card.Footer className="task-card-footer">
|
||||
<TaskTag
|
||||
tags={note.tags}
|
||||
lastUpdate={note.lastUpdate}
|
||||
taskOrNote="note"
|
||||
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setModalTitle(note.title);
|
||||
setModalContent(note.description);
|
||||
setShowMarkdownView(true);
|
||||
localStorage.setItem(
|
||||
OPEN_NOTE_ID_KEY,
|
||||
note.id.toString()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<ModalMarkdown
|
||||
show={showMarkdownView}
|
||||
onHide={handleCloseModal}
|
||||
title={modalTitle}
|
||||
markdownText={modalContent}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
show={showDeleteModal}
|
||||
onHide={() => setShowDeleteModal(false)}
|
||||
centered
|
||||
backdrop="static"
|
||||
>
|
||||
<Modal.Header closeButton className="bg-danger-subtle">
|
||||
<Modal.Title className="d-flex align-items-center gap-2">
|
||||
<i className="bi bi-exclamation-triangle-fill text-danger"></i>
|
||||
{t('delete_modal_title')}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>{t('delete_modal_body')}</Modal.Body>
|
||||
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
}}
|
||||
className="task-note-btn"
|
||||
>
|
||||
{t('delete_modal_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleConfirmDelete}
|
||||
className="task-note-btn"
|
||||
data-testid="confirm-delete-button"
|
||||
>
|
||||
{t('delete_modal_confirm')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
position: relative;
|
||||
text-align: center;
|
||||
color: $dark-text;
|
||||
background: var(--bs-landing-bg) no-repeat center center;
|
||||
background: var(--bs-landing-bg) no-repeat center center fixed;
|
||||
background-size: cover; // Ensures the image covers the whole background
|
||||
min-height: 100vh;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
@import '../../styles/theme.scss';
|
||||
|
||||
.login-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bs-landing-bg) no-repeat center center;
|
||||
background: var(--bs-landing-bg) no-repeat center center fixed;
|
||||
background-size: cover;
|
||||
|
||||
&::before {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
@@ -23,6 +24,13 @@ import ContentHeader from '../../components/ContentHeader';
|
||||
|
||||
type NoteAction = 'add' | 'edit';
|
||||
|
||||
interface NoteDraft {
|
||||
title: string;
|
||||
content: string;
|
||||
noteUrl: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* NoteAdd component for adding and editing notes.
|
||||
*
|
||||
@@ -41,15 +49,20 @@ function NoteAdd(): React.ReactNode {
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const [action, setAction] = useState<NoteAction>('add');
|
||||
const [showPreviewMd, setShowPreviewMd] = useState<boolean>(false);
|
||||
const [draftBanner, setDraftBanner] = useState<boolean>(false);
|
||||
const { i18n, t } = useTranslation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const tagContainerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasUserEdited = useRef<boolean>(false);
|
||||
|
||||
const draftKey = params?.id ? `draft:note:edit:${params.id}` : 'draft:note:new';
|
||||
|
||||
const loadTags = async (): Promise<void> => {
|
||||
try {
|
||||
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
|
||||
setTags(response);
|
||||
setTags(response.filter(tag => tag !== 'untagged'));
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -84,7 +97,6 @@ function NoteAdd(): React.ReactNode {
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -115,28 +127,130 @@ function NoteAdd(): React.ReactNode {
|
||||
setNoteContent('');
|
||||
setCurrentTag('');
|
||||
setSelectedTags([]);
|
||||
|
||||
setAction('add');
|
||||
setValidated(false);
|
||||
};
|
||||
|
||||
const saveDraft = (title: string, content: string, noteUrl: string, draftTags: string[]): void => {
|
||||
if (!hasUserEdited.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const draft: NoteDraft = { title, content, noteUrl, tags: draftTags };
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const clearDraft = (): void => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
localStorage.removeItem(draftKey);
|
||||
};
|
||||
|
||||
const applyDraft = (): void => {
|
||||
const raw = localStorage.getItem(draftKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft: NoteDraft = JSON.parse(raw);
|
||||
setNoteTitle(draft.title ?? '');
|
||||
setNoteContent(draft.content ?? '');
|
||||
setNoteUrl(draft.noteUrl ?? '');
|
||||
setSelectedTags(draft.tags ?? []);
|
||||
setDraftBanner(true);
|
||||
}
|
||||
catch {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardDraft = async (): Promise<void> => {
|
||||
setDraftBanner(false);
|
||||
if (params?.id) {
|
||||
try {
|
||||
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
|
||||
setNoteFromServer(noteToEdit);
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
finally {
|
||||
clearDraft();
|
||||
}
|
||||
}
|
||||
else {
|
||||
clearDraft();
|
||||
resetInputs();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (tagName: string): void => {
|
||||
const normalized = tagName.trim().toLowerCase();
|
||||
let newTags = [...selectedTags];
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
setSelectedTags([...selectedTags, normalized]);
|
||||
newTags = [...selectedTags, normalized];
|
||||
setSelectedTags(newTags);
|
||||
}
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, noteUrl, newTags);
|
||||
setCurrentTag('');
|
||||
setShowTagDropdown(false);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string): void => {
|
||||
setSelectedTags(selectedTags.filter(t => t !== tagToRemove));
|
||||
const newTags = selectedTags.filter(t => t !== tagToRemove);
|
||||
setSelectedTags(newTags);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, noteUrl, newTags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves the note, either adding a new one or editing an existing one.
|
||||
*
|
||||
* @returns {Promise<boolean>} True if the note was saved successfully, false otherwise.
|
||||
*/
|
||||
const saveNote = async (): Promise<boolean> => {
|
||||
setValidated(true);
|
||||
|
||||
if (!noteTitle.trim() || !noteContent.trim()) {
|
||||
setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language));
|
||||
return false;
|
||||
}
|
||||
|
||||
const finalTags = [...selectedTags];
|
||||
if (currentTag.trim()) {
|
||||
const normalized = currentTag.trim().toLowerCase();
|
||||
if (!finalTags.includes(normalized)) {
|
||||
finalTags.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
const payload: NoteResponse = {
|
||||
id: action === 'edit' ? noteId : 0,
|
||||
title: noteTitle,
|
||||
description: noteContent,
|
||||
url: noteUrl,
|
||||
tags: finalTags,
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null,
|
||||
archived: false
|
||||
};
|
||||
|
||||
const saved = action === 'add'
|
||||
? await addNote(payload)
|
||||
: await submitEditNote(payload);
|
||||
|
||||
if (saved) {
|
||||
clearDraft();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
}
|
||||
|
||||
return saved;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the form submission.
|
||||
*
|
||||
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
|
||||
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
|
||||
*/
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
@@ -149,53 +263,7 @@ function NoteAdd(): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add current tag if not empty before submitting
|
||||
const finalTags = [...selectedTags];
|
||||
if (currentTag.trim()) {
|
||||
const normalized = currentTag.trim().toLowerCase();
|
||||
if (!finalTags.includes(normalized)) {
|
||||
finalTags.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
const payload: NoteResponse = {
|
||||
id: 0,
|
||||
title: noteTitle,
|
||||
description: noteContent,
|
||||
url: noteUrl,
|
||||
tags: finalTags,
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
};
|
||||
|
||||
const added: boolean = await addNote(payload);
|
||||
if (added) {
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
}
|
||||
}
|
||||
else if (action === 'edit') {
|
||||
const payload: NoteResponse = {
|
||||
id: noteId,
|
||||
title: noteTitle,
|
||||
description: noteContent,
|
||||
url: noteUrl,
|
||||
tags: finalTags,
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
};
|
||||
|
||||
const edited: boolean = await submitEditNote(payload);
|
||||
if (edited) {
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
}
|
||||
}
|
||||
await saveNote();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -207,6 +275,7 @@ function NoteAdd(): React.ReactNode {
|
||||
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
|
||||
setNoteFromServer(noteToEdit);
|
||||
setAction('edit');
|
||||
applyDraft();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -238,16 +307,16 @@ function NoteAdd(): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
const setNoteFromServer = (noteContent: NoteResponse) => {
|
||||
setNoteId(noteContent.id);
|
||||
setNoteTitle(noteContent.title);
|
||||
if (noteContent.url) {
|
||||
setNoteUrl(noteContent.url);
|
||||
const setNoteFromServer = (noteData: NoteResponse) => {
|
||||
setNoteId(noteData.id);
|
||||
setNoteTitle(noteData.title);
|
||||
if (noteData.url) {
|
||||
setNoteUrl(noteData.url);
|
||||
}
|
||||
if (noteContent.tags) {
|
||||
setSelectedTags(noteContent.tags);
|
||||
if (noteData.tags) {
|
||||
setSelectedTags(noteData.tags);
|
||||
}
|
||||
setNoteContent(noteContent.description);
|
||||
setNoteContent(noteData.description);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -271,6 +340,10 @@ function NoteAdd(): React.ReactNode {
|
||||
checkEditUrl();
|
||||
checkCloneUrl();
|
||||
|
||||
if (!params?.id && !window.location.search.includes('cloneFrom=')) {
|
||||
applyDraft();
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
|
||||
setShowTagDropdown(false);
|
||||
@@ -280,6 +353,7 @@ function NoteAdd(): React.ReactNode {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -304,28 +378,47 @@ function NoteAdd(): React.ReactNode {
|
||||
onClose={() => setErrorMessage('')}
|
||||
/>
|
||||
|
||||
{draftBanner && (
|
||||
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
|
||||
Draft restored from a previous session.
|
||||
{' '}
|
||||
<Alert.Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
void handleDiscardDraft();
|
||||
}}
|
||||
>
|
||||
Discard draft
|
||||
</Alert.Link>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
noValidate
|
||||
validated={validated}
|
||||
onSubmit={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
{/* Note title */}
|
||||
<FormInput
|
||||
labelText={t('note_form_title_label')}
|
||||
iconName="JournalCheck"
|
||||
required={true}
|
||||
type="text"
|
||||
name="note_title"
|
||||
placeholder={t('note_form_title_placeholder')}
|
||||
value={noteTitle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteTitle(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Row>
|
||||
<Col xs={12} xl={9}>
|
||||
<Col xs={12} md={6} xxl={6}>
|
||||
{/* Note title */}
|
||||
<FormInput
|
||||
labelText={t('note_form_title_label')}
|
||||
iconName="JournalCheck"
|
||||
required={true}
|
||||
type="text"
|
||||
name="note_title"
|
||||
placeholder={t('note_form_title_placeholder')}
|
||||
value={noteTitle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteTitle(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(e.target.value, noteContent, noteUrl, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={6} xxl={6}>
|
||||
{/* Note URL */}
|
||||
<FormInput
|
||||
labelText={t('task_form_url_label')}
|
||||
@@ -337,14 +430,18 @@ function NoteAdd(): React.ReactNode {
|
||||
value={noteUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteUrl(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, e.target.value, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} xl={3}>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
{/* Tag with suggestion dropdown */}
|
||||
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
|
||||
<Form.Label>Tags</Form.Label>
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
<Hash />
|
||||
</InputGroup.Text>
|
||||
@@ -367,13 +464,16 @@ function NoteAdd(): React.ReactNode {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
<Form.Text className="text-muted">
|
||||
Type a tag and press Enter
|
||||
</Form.Text>
|
||||
<div className="mb-2 d-flex flex-wrap gap-1">
|
||||
{selectedTags.map(t => (
|
||||
<Badge
|
||||
key={t}
|
||||
bg="warning"
|
||||
text="dark"
|
||||
className="p-2"
|
||||
className="p-2 mt-3"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => removeTag(t)}
|
||||
>
|
||||
@@ -401,11 +501,14 @@ function NoteAdd(): React.ReactNode {
|
||||
<ListGroup.Item
|
||||
key={t}
|
||||
action
|
||||
variant="warning"
|
||||
className="d-flex align-items-center gap-2"
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
addTag(t);
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-tag"></i>
|
||||
#
|
||||
{t}
|
||||
</ListGroup.Item>
|
||||
@@ -438,27 +541,32 @@ function NoteAdd(): React.ReactNode {
|
||||
value={noteContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setNoteContent(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, e.target.value, noteUrl, selectedTags);
|
||||
}}
|
||||
data-testid="note-content-input-area"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="home-new-item task-note-btn mt-3"
|
||||
>
|
||||
{t('note_form_submit')}
|
||||
</button>
|
||||
<div className="d-flex justify-content-end gap-2 mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="home-new-item task-note-btn"
|
||||
>
|
||||
{t('note_form_submit')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ms-2 home-new-item-secondary task-note-btn"
|
||||
onClick={() => {
|
||||
navigate('/home');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="home-new-item-secondary task-note-btn"
|
||||
onClick={() => {
|
||||
clearDraft();
|
||||
navigate('/home');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
</Card.Body>
|
||||
@@ -471,6 +579,8 @@ function NoteAdd(): React.ReactNode {
|
||||
onHide={handleCloseModal}
|
||||
title={noteTitle}
|
||||
markdownText={noteContent}
|
||||
onSave={saveNote}
|
||||
saveButtonLabel={t('note_form_submit')}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
@@ -23,6 +24,14 @@ import AlertError from '../../components/AlertError';
|
||||
|
||||
type TaskAction = 'add' | 'edit';
|
||||
|
||||
interface TaskDraft {
|
||||
description: string;
|
||||
taskUrl: string;
|
||||
dueDate: string | null;
|
||||
highPriority: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskAdd component for adding and editing tasks.
|
||||
*
|
||||
@@ -34,23 +43,28 @@ function TaskAdd(): React.ReactNode {
|
||||
const [taskId, setTaskId] = useState<number>(0);
|
||||
const [taskDescription, setTaskDescription] = useState<string>('');
|
||||
const [taskUrl, setTaskUrl] = useState<string>('');
|
||||
const [taskDone, setTaskDone] = useState<boolean>(false);
|
||||
const [taskCompleted, setTaskCompleted] = useState<boolean>(false);
|
||||
const [action, setAction] = useState<TaskAction>('add');
|
||||
const [dueDate, setDueDate] = useState<Date | null>(null);
|
||||
const [dueDate, setDueDate] = useState<string>('');
|
||||
const [highPriority, setHighPriority] = useState<boolean>(false);
|
||||
const [currentTag, setCurrentTag] = useState<string>('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const [draftBanner, setDraftBanner] = useState<boolean>(false);
|
||||
const { i18n, t } = useTranslation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const tagContainerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasUserEdited = useRef<boolean>(false);
|
||||
|
||||
const draftKey = params?.id ? `draft:task:edit:${params.id}` : 'draft:task:new';
|
||||
|
||||
const loadTags = async (): Promise<void> => {
|
||||
try {
|
||||
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
|
||||
setTags(response);
|
||||
setTags(response.filter(tag => tag !== 'untagged'));
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -85,7 +99,6 @@ function TaskAdd(): React.ReactNode {
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -112,34 +125,118 @@ function TaskAdd(): React.ReactNode {
|
||||
const resetInputs = (): void => {
|
||||
setTaskId(0);
|
||||
setTaskDescription('');
|
||||
setTaskDone(false);
|
||||
setTaskCompleted(false);
|
||||
setTaskUrl('');
|
||||
setDueDate(null);
|
||||
setDueDate('');
|
||||
setHighPriority(false);
|
||||
setCurrentTag('');
|
||||
setSelectedTags([]);
|
||||
|
||||
setAction('add');
|
||||
setValidated(false);
|
||||
};
|
||||
|
||||
const saveDraft = (
|
||||
description: string,
|
||||
taskUrl: string,
|
||||
due: string,
|
||||
priority: boolean,
|
||||
draftTags: string[]
|
||||
): void => {
|
||||
if (!hasUserEdited.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const draft: TaskDraft = {
|
||||
description,
|
||||
taskUrl,
|
||||
dueDate: due || null,
|
||||
highPriority: priority,
|
||||
tags: draftTags
|
||||
};
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const clearDraft = (): void => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
localStorage.removeItem(draftKey);
|
||||
};
|
||||
|
||||
const applyDraft = (): void => {
|
||||
const raw = localStorage.getItem(draftKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft: TaskDraft = JSON.parse(raw);
|
||||
setTaskDescription(draft.description ?? '');
|
||||
setTaskUrl(draft.taskUrl ?? '');
|
||||
const parsedDate = draft.dueDate ? draft.dueDate : '';
|
||||
setDueDate(parsedDate);
|
||||
setHighPriority(draft.highPriority ?? false);
|
||||
setSelectedTags(draft.tags ?? []);
|
||||
setDraftBanner(true);
|
||||
}
|
||||
catch {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
};
|
||||
|
||||
const setTaskFromServer = (task: TaskResponse): void => {
|
||||
setTaskId(task.id);
|
||||
setTaskDescription(task.description);
|
||||
setTaskUrl(task.urls.length ? task.urls[0] : '');
|
||||
setTaskCompleted(task.completed);
|
||||
if (task.dueDateFmt) {
|
||||
setDueDate(task.dueDate);
|
||||
}
|
||||
setHighPriority(task.highPriority);
|
||||
if (task.tags) {
|
||||
setSelectedTags(task.tags);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardDraft = async (): Promise<void> => {
|
||||
setDraftBanner(false);
|
||||
if (params?.id) {
|
||||
try {
|
||||
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
|
||||
setTaskFromServer(taskToEdit);
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
finally {
|
||||
clearDraft();
|
||||
}
|
||||
}
|
||||
else {
|
||||
clearDraft();
|
||||
resetInputs();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (tagName: string): void => {
|
||||
const normalized = tagName.trim().toLowerCase();
|
||||
let newTags = [...selectedTags];
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
setSelectedTags([...selectedTags, normalized]);
|
||||
newTags = [...selectedTags, normalized];
|
||||
setSelectedTags(newTags);
|
||||
}
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
|
||||
setCurrentTag('');
|
||||
setShowTagDropdown(false);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string): void => {
|
||||
setSelectedTags(selectedTags.filter(t => t !== tagToRemove));
|
||||
const newTags = selectedTags.filter(t => t !== tagToRemove);
|
||||
setSelectedTags(newTags);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the form submission.
|
||||
*
|
||||
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
|
||||
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
|
||||
*/
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
@@ -152,12 +249,8 @@ function TaskAdd(): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
let dueDateFormatted: string = '';
|
||||
if (dueDate) {
|
||||
dueDateFormatted = dueDate.toISOString().substring(0, 10);
|
||||
}
|
||||
const dueDateFormatted: string = dueDate;
|
||||
|
||||
// Add current tag if not empty before submitting
|
||||
const finalTags = [...selectedTags];
|
||||
if (currentTag.trim()) {
|
||||
const normalized = currentTag.trim().toLowerCase();
|
||||
@@ -177,6 +270,7 @@ function TaskAdd(): React.ReactNode {
|
||||
|
||||
const added: boolean = await addTask(addPayload);
|
||||
if (added) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -186,7 +280,7 @@ function TaskAdd(): React.ReactNode {
|
||||
const editPayload: TaskResponse = {
|
||||
id: taskId,
|
||||
description: taskDescription.trim(),
|
||||
done: taskDone,
|
||||
completed: taskCompleted,
|
||||
highPriority: highPriority,
|
||||
dueDate: dueDateFormatted,
|
||||
dueDateFmt: '',
|
||||
@@ -197,6 +291,7 @@ function TaskAdd(): React.ReactNode {
|
||||
|
||||
const edited: boolean = await submitEditTask(editPayload);
|
||||
if (edited) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -211,18 +306,9 @@ function TaskAdd(): React.ReactNode {
|
||||
if (params.id) {
|
||||
try {
|
||||
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
|
||||
setTaskId(taskToEdit.id);
|
||||
setTaskDescription(taskToEdit.description);
|
||||
setTaskUrl(taskToEdit.urls.length ? taskToEdit.urls[0] : '');
|
||||
setTaskDone(taskToEdit.done);
|
||||
if (taskToEdit.dueDateFmt) {
|
||||
setDueDate(new Date(taskToEdit.dueDate));
|
||||
}
|
||||
setHighPriority(taskToEdit.highPriority);
|
||||
if (taskToEdit.tags) {
|
||||
setSelectedTags(taskToEdit.tags);
|
||||
}
|
||||
setTaskFromServer(taskToEdit);
|
||||
setAction('edit');
|
||||
applyDraft();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -234,6 +320,10 @@ function TaskAdd(): React.ReactNode {
|
||||
loadTags();
|
||||
checkEditUrl();
|
||||
|
||||
if (!params?.id) {
|
||||
applyDraft();
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
|
||||
setShowTagDropdown(false);
|
||||
@@ -243,6 +333,7 @@ function TaskAdd(): React.ReactNode {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -268,28 +359,63 @@ function TaskAdd(): React.ReactNode {
|
||||
onClose={() => setErrorMessage('')}
|
||||
/>
|
||||
|
||||
{draftBanner && (
|
||||
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
|
||||
Draft restored from a previous session.
|
||||
{' '}
|
||||
<Alert.Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
void handleDiscardDraft();
|
||||
}}
|
||||
>
|
||||
Discard draft
|
||||
</Alert.Link>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
noValidate
|
||||
validated={validated}
|
||||
onSubmit={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
{/* Description */}
|
||||
<FormInput
|
||||
labelText={t('task_form_desc_label')}
|
||||
iconName="PencilFill"
|
||||
required={true}
|
||||
type="text"
|
||||
name="description"
|
||||
placeholder={t('task_form_desc_placeholder')}
|
||||
value={taskDescription}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskDescription(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Row>
|
||||
<Col xs={12} sm={12} xxl={6}>
|
||||
<Col xs={12} md={6} xxl={6}>
|
||||
{/* Description */}
|
||||
<FormInput
|
||||
labelText={t('task_form_desc_label')}
|
||||
iconName="PencilFill"
|
||||
required={true}
|
||||
type="text"
|
||||
name="description"
|
||||
placeholder={t('task_form_desc_placeholder')}
|
||||
value={taskDescription}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskDescription(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(e.target.value, taskUrl, dueDate, highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={3} xxl={3}>
|
||||
{/* Due date */}
|
||||
<FormInput
|
||||
labelText={t('task_form_duedate_label')}
|
||||
iconName="CalendarCheck"
|
||||
required={false}
|
||||
type="date"
|
||||
name="dueDate"
|
||||
placeholder={t('task_form_duedate_placeholder')}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDueDate(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, e.target.value, highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={3} xxl={3}>
|
||||
{/* Task URL */}
|
||||
<FormInput
|
||||
labelText={t('task_form_url_label')}
|
||||
@@ -301,29 +427,19 @@ function TaskAdd(): React.ReactNode {
|
||||
value={taskUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskUrl(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, e.target.value, dueDate, highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} xxl={6}>
|
||||
{/* Due date */}
|
||||
<FormInput
|
||||
labelText={t('task_form_duedate_label')}
|
||||
iconName="CalendarCheck"
|
||||
required={false}
|
||||
type="date"
|
||||
name="dueDate"
|
||||
placeholder={t('task_form_duedate_placeholder')}
|
||||
valueDate={dueDate}
|
||||
onChangeDate={(date: Date | null) => {
|
||||
setDueDate(date);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} xxl={12}>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
{/* Tag with suggestion dropdown */}
|
||||
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
|
||||
<Form.Label>Tags</Form.Label>
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
<Hash />
|
||||
</InputGroup.Text>
|
||||
@@ -346,13 +462,16 @@ function TaskAdd(): React.ReactNode {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
<Form.Text className="text-muted mb-3">
|
||||
Type a tag and press Enter
|
||||
</Form.Text>
|
||||
<div className="mb-2 d-flex flex-wrap gap-1">
|
||||
{selectedTags.map(t => (
|
||||
<Badge
|
||||
key={t}
|
||||
bg="warning"
|
||||
text="dark"
|
||||
className="p-2"
|
||||
className="p-2 mt-3"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => removeTag(t)}
|
||||
>
|
||||
@@ -380,11 +499,14 @@ function TaskAdd(): React.ReactNode {
|
||||
<ListGroup.Item
|
||||
key={t}
|
||||
action
|
||||
variant="warning"
|
||||
className="d-flex align-items-center gap-2"
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
addTag(t);
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-tag"></i>
|
||||
#
|
||||
{t}
|
||||
</ListGroup.Item>
|
||||
@@ -402,25 +524,32 @@ function TaskAdd(): React.ReactNode {
|
||||
className="mb-3"
|
||||
name="highPriority"
|
||||
checked={highPriority}
|
||||
onChange={() => setHighPriority(!highPriority)}
|
||||
onChange={() => {
|
||||
setHighPriority(!highPriority);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, !highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="home-new-item task-note-btn"
|
||||
>
|
||||
{t('task_form_submit')}
|
||||
</button>
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="home-new-item task-note-btn"
|
||||
>
|
||||
{t('task_form_submit')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ms-2 home-new-item-secondary task-note-btn"
|
||||
onClick={() => {
|
||||
navigate('/home');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="home-new-item-secondary task-note-btn"
|
||||
onClick={() => {
|
||||
clearDraft();
|
||||
navigate('/home');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
+14
-2
@@ -3,6 +3,16 @@ import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
const proxyConfig = process.env.NGROK
|
||||
? {
|
||||
'/api': {
|
||||
target: `http://${process.env.BACKEND_HOST || 'localhost'}:8585`,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
export default defineConfig(({ mode }: ConfigEnv) => {
|
||||
const config: UserConfig = {
|
||||
define: {},
|
||||
@@ -29,10 +39,12 @@ export default defineConfig(({ mode }: ConfigEnv) => {
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
sourcemap: mode === 'development'
|
||||
},
|
||||
server: {
|
||||
port: 5000
|
||||
port: 5000,
|
||||
...(process.env.NGROK ? { allowedHosts: ['.ngrok-free.dev'] } : {}),
|
||||
proxy: proxyConfig,
|
||||
},
|
||||
preview: {
|
||||
port: 5000
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
|
||||
PR="$1"
|
||||
echo "PR: $PR"
|
||||
|
||||
echo "Getting env vars..."
|
||||
export $(cat .env | xargs)
|
||||
|
||||
docker run -d --rm \
|
||||
--name server \
|
||||
--network=host \
|
||||
-e POSTGRES_DB=$POSTGRES_DB \
|
||||
-e POSTGRES_USER=$POSTGRES_USER \
|
||||
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||
-e POSTGRES_PORT=$POSTGRES_PORT \
|
||||
-e POSTGRES_HOST=$POSTGRES_HOST \
|
||||
-e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \
|
||||
-e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \
|
||||
-e MAILGUN_APIKEY=$MAILGUN_APIKEY \
|
||||
-e SECURITY_KEY=$SECURITY_KEY \
|
||||
-e BUILD=ghcr.io/ricardo-campos-org/react-typescript-todolist/server:$PR \
|
||||
ghcr.io/ricardo-campos-org/react-typescript-todolist/server:$PR
|
||||
@@ -7,10 +7,12 @@ services:
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
ports:
|
||||
- "5000:5000"
|
||||
entrypoint: sh -c "npm i --no-update-notifier && npm start"
|
||||
entrypoint: sh -c "npm i --no-update-notifier && export NGROK=1 && npm start"
|
||||
environment:
|
||||
VITE_BACKEND_SERVER: "/api"
|
||||
VITE_BUILD: nightly
|
||||
NGROK: 1
|
||||
BACKEND_HOST: tasknote-api
|
||||
volumes:
|
||||
- "./client:/app"
|
||||
working_dir: /app
|
||||
@@ -91,4 +93,3 @@ services:
|
||||
|
||||
networks:
|
||||
tasknote:
|
||||
external: true
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
depends_on:
|
||||
tasknote-api:
|
||||
condition: service_healthy
|
||||
image: ghcr.io/rmcampos/tasknote/app:latest
|
||||
image: rmcampos/tasknote-app:latest
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
@@ -23,15 +23,15 @@ services:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_HOST: tasknote-db
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_PORT: 5432
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000, https://flattop-depth-dropper.ngrok-free.dev
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000
|
||||
SERVER_SERVLET_CONTEXT_PATH: /
|
||||
TARGET_ENV: production
|
||||
SECURITY_KEY: ${SECURITY_KEY:-default-security-key}
|
||||
MAILGUN_APIKEY: ${MAILGUN_APIKEY:-default-mailgun-apikey}
|
||||
SECURITY_KEY: ${SECURITY_KEY}
|
||||
MAILGUN_APIKEY: ${MAILGUN_APIKEY}
|
||||
ports: ["8585:8585"]
|
||||
image: ghcr.io/rmcampos/tasknote/api:latest
|
||||
image: rmcampos/tasknote-api:latest
|
||||
healthcheck:
|
||||
test: ["CMD", "/layers/local_healthcheck/healthcheck/bin/healthcheck"]
|
||||
interval: 10s
|
||||
@@ -47,8 +47,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
ports: ["5432:5432"]
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
healthcheck:
|
||||
test: psql -q -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1'
|
||||
interval: 1m30s
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
setup:
|
||||
project: tasknote
|
||||
config: prd
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
CONTAINER="tasknote-db"
|
||||
SQL_USER="tasknoteuser"
|
||||
SQL_DATABASE="tasknote"
|
||||
SQL_SCHEMA="tasknote"
|
||||
SQL_TABLE_NAME="users"
|
||||
SQL_QUERY="UPDATE $SQL_SCHEMA.$SQL_TABLE_NAME SET email_confirmed_at = created_at WHERE id = 1;"
|
||||
|
||||
docker exec -it $CONTAINER \
|
||||
psql \
|
||||
-U $SQL_USER \
|
||||
-d $SQL_DATABASE \
|
||||
-c "$SQL_QUERY"
|
||||
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "react-typescript-todolist",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
|
||||
<groupId>br.com.tasknoteapp</groupId>
|
||||
<artifactId>server</artifactId>
|
||||
<version>31</version>
|
||||
<version>35</version>
|
||||
<name>tasknote-api</name>
|
||||
<description>Java backend REST API to serve TaskNote frontend client</description>
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>13.3.0</version>
|
||||
<version>13.7.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[build]
|
||||
builder = "paketobuildpacks/builder-jammy-tiny:latest"
|
||||
builder = "paketobuildpacks/builder-jammy-tiny:0.0.505"
|
||||
|
||||
[[build.buildpacks]]
|
||||
uri = "buildpacks/healthcheck"
|
||||
|
||||
@@ -56,7 +56,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/rest/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.permitAll())
|
||||
.denyAll())
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(
|
||||
@@ -77,7 +77,7 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
return new BCryptPasswordEncoder(12);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,4 +115,28 @@ public class NoteController {
|
||||
public ResponseEntity<NoteResponse> unshareNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.unshareNote(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a note, disabling edits and revoking public sharing.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse with the archived note.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@PutMapping("/{id}/archive")
|
||||
public ResponseEntity<NoteResponse> archiveNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.archiveNote(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an archived note back to active state.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse with the restored note.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@PutMapping("/{id}/restore")
|
||||
public ResponseEntity<NoteResponse> restoreNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.restoreNote(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ public class NoteEntity {
|
||||
@Column(name = "share_token", length = 36)
|
||||
private String shareToken;
|
||||
|
||||
@Column(name = "archived", nullable = false)
|
||||
private boolean archived = false;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -113,6 +116,14 @@ public class NoteEntity {
|
||||
this.shareToken = shareToken;
|
||||
}
|
||||
|
||||
public boolean isArchived() {
|
||||
return archived;
|
||||
}
|
||||
|
||||
public void setArchived(boolean archived) {
|
||||
this.archived = archived;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
@@ -145,6 +156,8 @@ public class NoteEntity {
|
||||
+ tags
|
||||
+ ", lastUpdate="
|
||||
+ lastUpdate
|
||||
+ ", archived="
|
||||
+ archived
|
||||
+ '}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public class TaskEntity {
|
||||
@Column(length = 2000)
|
||||
private String description;
|
||||
|
||||
private Boolean done;
|
||||
private Boolean completed;
|
||||
|
||||
@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, updatable = false)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@@ -66,12 +66,12 @@ public class TaskEntity {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Boolean getDone() {
|
||||
return done;
|
||||
public Boolean getCompleted() {
|
||||
return completed;
|
||||
}
|
||||
|
||||
public void setDone(Boolean done) {
|
||||
this.done = done;
|
||||
public void setCompleted(Boolean completed) {
|
||||
this.completed = completed;
|
||||
}
|
||||
|
||||
public UserEntity getUser() {
|
||||
@@ -139,8 +139,8 @@ public class TaskEntity {
|
||||
+ ", description='"
|
||||
+ description
|
||||
+ '\''
|
||||
+ ", done="
|
||||
+ done
|
||||
+ ", completed="
|
||||
+ completed
|
||||
+ ", lastUpdate="
|
||||
+ lastUpdate
|
||||
+ ", dueDate="
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
/** This class represents a conflict when an note is archived and cannot be modified. */
|
||||
public class NoteArchivedException extends BaseBadRequestException {
|
||||
|
||||
public NoteArchivedException() {
|
||||
super("note", "Note is archived");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.TaskEntity;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -11,6 +12,8 @@ public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
|
||||
|
||||
List<TaskEntity> findAllByUser_id(Long userId);
|
||||
|
||||
Optional<TaskEntity> findByIdAndUser_id(Long id, Long userId);
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select distinct t
|
||||
@@ -21,7 +24,7 @@ public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
|
||||
upper(t.description) like upper(concat('%', :searchTerm, '%')) or
|
||||
upper(tg.name) like upper(concat('%', :searchTerm, '%')) or
|
||||
upper(tu.id.url) like upper(concat('%', :searchTerm, '%'))
|
||||
) and t.user.id = :userId and t.done = false
|
||||
) and t.user.id = :userId and t.completed = false
|
||||
""")
|
||||
List<TaskEntity> findAllBySearchTerm(
|
||||
@Param("searchTerm") String searchTerm, @Param("userId") Long userId);
|
||||
|
||||
@@ -78,12 +78,8 @@ public class LoginRequest {
|
||||
+ "email='"
|
||||
+ email
|
||||
+ '\''
|
||||
+ ", password='"
|
||||
+ password
|
||||
+ '\''
|
||||
+ ", passwordAgain='"
|
||||
+ passwordAgain
|
||||
+ '\''
|
||||
+ ", password='[REDACTED]'"
|
||||
+ ", passwordAgain='[REDACTED]'"
|
||||
+ ", lang='"
|
||||
+ lang
|
||||
+ '\''
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a note patch payload. */
|
||||
public record NotePatchRequest(
|
||||
String title,
|
||||
String description,
|
||||
@Pattern(
|
||||
@Size(max = 100) String title,
|
||||
@Size(max = 50000) String description,
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
String url,
|
||||
|
||||
@@ -2,13 +2,15 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a note request to be created. */
|
||||
public record NoteRequest(
|
||||
@NotNull String title,
|
||||
@NotNull String description,
|
||||
@Pattern(
|
||||
@NotNull @Size(max = 100) String title,
|
||||
@NotNull @Size(max = 50000) String description,
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
String url,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task patch payload. */
|
||||
public record TaskPatchRequest(
|
||||
String description,
|
||||
Boolean done,
|
||||
Boolean completed,
|
||||
@Size(max = 2000) String description,
|
||||
List<
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
|
||||
@@ -3,12 +3,14 @@ package br.com.tasknoteapp.server.request;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task request to be created. */
|
||||
public record TaskRequest(
|
||||
@NotNull @NotEmpty String description,
|
||||
@NotNull @NotEmpty @Size(max = 2000) String description,
|
||||
List<
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
|
||||
@@ -2,4 +2,10 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
/** This record represents a user patch payload. */
|
||||
public record UserPatchRequest(
|
||||
String name, String email, String password, String passwordAgain, String lang) {}
|
||||
String name,
|
||||
String email,
|
||||
String password,
|
||||
String passwordAgain,
|
||||
String lang,
|
||||
String currentPassword) {}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ public record NoteResponse(
|
||||
String lastUpdate,
|
||||
List<String> tags,
|
||||
boolean shared,
|
||||
String shareToken) {
|
||||
String shareToken,
|
||||
boolean archived) {
|
||||
|
||||
/**
|
||||
* Creates a NoteResponse given a NoteEntity and its Urals.
|
||||
@@ -34,6 +35,7 @@ public record NoteResponse(
|
||||
timeAgoFmt,
|
||||
entity.getTags().stream().map(TagEntity::getName).toList(),
|
||||
entity.isShared(),
|
||||
entity.getShareToken());
|
||||
entity.getShareToken(),
|
||||
entity.isArchived());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import java.util.List;
|
||||
/** This record represents a task and its urls object to be returned. */
|
||||
public record TaskResponse(
|
||||
Long id,
|
||||
Boolean completed,
|
||||
String description,
|
||||
Boolean done,
|
||||
Boolean highPriority,
|
||||
LocalDate dueDate,
|
||||
String dueDateFmt,
|
||||
@@ -31,8 +31,8 @@ public record TaskResponse(
|
||||
|
||||
return new TaskResponse(
|
||||
entity.getId(),
|
||||
entity.getCompleted(),
|
||||
entity.getDescription(),
|
||||
entity.getDone(),
|
||||
entity.getHighPriority(),
|
||||
entity.getDueDate(),
|
||||
dueDateFmt,
|
||||
|
||||
@@ -292,7 +292,7 @@ public class AuthService {
|
||||
|
||||
String token = jwtService.generateToken(currentUser);
|
||||
|
||||
logger.info("User refreshed! Token {}", token);
|
||||
logger.info("User refreshed! Token {}...", token.substring(0, 6));
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -329,11 +329,26 @@ public class AuthService {
|
||||
boolean shouldUpdate = false;
|
||||
boolean emailChanged = false;
|
||||
|
||||
boolean changingEmail =
|
||||
!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank();
|
||||
boolean changingPassword =
|
||||
!Objects.isNull(patchRequest.password()) && !patchRequest.password().isBlank();
|
||||
|
||||
if (changingEmail || changingPassword) {
|
||||
if (Objects.isNull(patchRequest.currentPassword())
|
||||
|| patchRequest.currentPassword().isBlank()) {
|
||||
throw new BadPasswordException("Current password is required to change email or password");
|
||||
}
|
||||
if (!passwordEncoder.matches(patchRequest.currentPassword(), currentUser.getPassword())) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
}
|
||||
|
||||
if (!Objects.isNull(patchRequest.name()) && !patchRequest.name().isBlank()) {
|
||||
currentUser.setName(patchRequest.name().trim());
|
||||
shouldUpdate = true;
|
||||
}
|
||||
if (!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank()) {
|
||||
if (changingEmail) {
|
||||
currentUser.setEmail(patchRequest.email().trim());
|
||||
shouldUpdate = true;
|
||||
emailChanged = true;
|
||||
@@ -344,8 +359,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
boolean updatePassword =
|
||||
!Objects.isNull(patchRequest.password())
|
||||
&& !patchRequest.password().isBlank()
|
||||
changingPassword
|
||||
&& !Objects.isNull(patchRequest.passwordAgain())
|
||||
&& !patchRequest.passwordAgain().isBlank();
|
||||
|
||||
@@ -563,11 +577,11 @@ public class AuthService {
|
||||
|
||||
// if it's more than 3 times in the last 10 minutes, raise timer of 3 hours.
|
||||
if (userPwdList.size() >= 3) {
|
||||
UserPwdLimitEntity mostRecent = userPwdList.getFirst();
|
||||
logger.warn("Oldest: {}", mostRecent.getWhenHappened());
|
||||
Duration duration = Duration.between(mostRecent.getWhenHappened(), LocalDateTime.now());
|
||||
UserPwdLimitEntity oldest = userPwdList.getLast();
|
||||
logger.warn("Oldest failed attempt: {}", oldest.getWhenHappened());
|
||||
Duration duration = Duration.between(oldest.getWhenHappened(), LocalDateTime.now());
|
||||
if (duration.toMinutes() <= 3L) {
|
||||
logger.warn("Wait more {}", 3L - duration.toMinutes());
|
||||
logger.warn("Account locked, minutes remaining: {}", 3L - duration.toMinutes());
|
||||
throw new MaxLoginLimitAttemptException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
|
||||
import br.com.tasknoteapp.server.entity.TagEntity;
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.exception.NoteArchivedException;
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.repository.NoteRepository;
|
||||
import br.com.tasknoteapp.server.repository.NoteUrlRepository;
|
||||
@@ -160,6 +161,10 @@ public class NoteService {
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = note.get();
|
||||
if (noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
if (!Objects.isNull(patch.title()) && !patch.title().isBlank()) {
|
||||
noteEntity.setTitle(patch.title().trim());
|
||||
}
|
||||
@@ -208,6 +213,9 @@ public class NoteService {
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = note.get();
|
||||
if (!noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
noteUrlRepository.deleteByNote_id(noteId);
|
||||
logger.info("URL deleted from note ID {}", noteId);
|
||||
@@ -255,6 +263,10 @@ public class NoteService {
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
|
||||
if (noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
if (!noteEntity.isShared()) {
|
||||
noteEntity.setShared(true);
|
||||
noteEntity.setShareToken(UUID.randomUUID().toString());
|
||||
@@ -282,6 +294,11 @@ public class NoteService {
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
|
||||
if (noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
noteEntity.setShared(false);
|
||||
noteEntity.setShareToken(null);
|
||||
noteRepository.save(noteEntity);
|
||||
@@ -301,7 +318,7 @@ public class NoteService {
|
||||
logger.info("Fetching shared note with token {}", shareToken);
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByShareToken(shareToken);
|
||||
if (noteOpt.isEmpty() || !noteOpt.get().isShared()) {
|
||||
if (noteOpt.isEmpty() || !noteOpt.get().isShared() || noteOpt.get().isArchived()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
@@ -352,6 +369,66 @@ public class NoteService {
|
||||
return notes.stream().map(n -> NoteResponse.fromEntity(n, noteUrls.get(n.getId()))).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a note, disabling edits and revoking public sharing.
|
||||
*
|
||||
* @param noteId The note id from the database.
|
||||
* @return {@link NoteResponse} containing the archived note.
|
||||
*/
|
||||
@Transactional
|
||||
public NoteResponse archiveNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Archiving note ID {} for user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (noteOpt.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
if (noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
noteEntity.setArchived(true);
|
||||
noteEntity.setShared(false);
|
||||
noteEntity.setShareToken(null);
|
||||
noteEntity.setLastUpdate(LocalDateTime.now());
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note ID {} archived", noteId);
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an archived note back to active state.
|
||||
*
|
||||
* @param noteId The note id from the database.
|
||||
* @return {@link NoteResponse} containing the restored note.
|
||||
*/
|
||||
@Transactional
|
||||
public NoteResponse restoreNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Restoring note ID {} for user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (noteOpt.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
if (!noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
noteEntity.setArchived(false);
|
||||
noteEntity.setLastUpdate(LocalDateTime.now());
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note ID {} restored", noteId);
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
|
||||
}
|
||||
|
||||
private NoteUrlEntity saveUrl(NoteEntity noteEntity, String url) {
|
||||
NoteUrlEntity noteUrl = new NoteUrlEntity();
|
||||
noteUrl.setUrl(url);
|
||||
|
||||
@@ -95,7 +95,7 @@ public class TaskService {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
|
||||
if (task.isEmpty()) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
@@ -117,7 +117,7 @@ public class TaskService {
|
||||
|
||||
TaskEntity task = new TaskEntity();
|
||||
task.setDescription(taskRequest.description());
|
||||
task.setDone(false);
|
||||
task.setCompleted(false);
|
||||
task.setUser(user);
|
||||
task.setLastUpdate(LocalDateTime.now());
|
||||
if (!Objects.isNull(taskRequest.dueDate()) && !taskRequest.dueDate().isBlank()) {
|
||||
@@ -152,7 +152,7 @@ public class TaskService {
|
||||
|
||||
logger.info("Patching task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
|
||||
if (task.isEmpty()) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
@@ -161,8 +161,8 @@ public class TaskService {
|
||||
if (!Objects.isNull(patch.description()) && !patch.description().isBlank()) {
|
||||
taskEntity.setDescription(patch.description().trim());
|
||||
}
|
||||
if (!Objects.isNull(patch.done())) {
|
||||
taskEntity.setDone(patch.done());
|
||||
if (!Objects.isNull(patch.completed())) {
|
||||
taskEntity.setCompleted(patch.completed());
|
||||
}
|
||||
|
||||
patchDueDate(taskEntity, patch);
|
||||
@@ -198,7 +198,7 @@ public class TaskService {
|
||||
|
||||
logger.info("Deleting task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
|
||||
if (task.isEmpty()) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
@@ -257,7 +257,7 @@ public class TaskService {
|
||||
|
||||
List<TaskEntity> allTasks =
|
||||
taskRepository.findAllByUser_id(user.getId()).stream()
|
||||
.filter(t -> t.getDone().equals(Boolean.FALSE))
|
||||
.filter(t -> t.getCompleted().equals(Boolean.FALSE))
|
||||
.toList();
|
||||
if (allTasks.isEmpty()) {
|
||||
return List.of();
|
||||
|
||||
@@ -3,8 +3,8 @@ package br.com.tasknoteapp.server.service.impl;
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.service.JwtService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.MalformedJwtException;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -29,7 +29,7 @@ class JwtServiceImpl implements JwtService {
|
||||
private static final long MINUTE = SECOND * 60;
|
||||
private static final long HOUR = MINUTE * 60;
|
||||
private static final long DAY = HOUR * 24;
|
||||
private static final long EXPIRATION_TIME = DAY * 7;
|
||||
private static final long EXPIRATION_TIME = MINUTE * 30;
|
||||
private final SecretKey key;
|
||||
|
||||
public JwtServiceImpl(@Value("${br.com.tasknote.server.jwt-secret}") String secretKey) {
|
||||
@@ -122,7 +122,7 @@ class JwtServiceImpl implements JwtService {
|
||||
try {
|
||||
return Optional.of(
|
||||
Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload());
|
||||
} catch (MalformedJwtException me) {
|
||||
} catch (JwtException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +89,9 @@ public class TimeAgoUtil {
|
||||
} else if (period.getDays() > 1) {
|
||||
sb.append(String.format("%d days left", period.getDays()));
|
||||
} else if (period.getDays() > 0) {
|
||||
sb.append(String.format("%d day left", period.getDays()));
|
||||
sb.append("Due tomorrow");
|
||||
} else if (period.getDays() == 0) {
|
||||
sb.append("0 days left");
|
||||
sb.append("Due today");
|
||||
} else {
|
||||
sb.append("Due");
|
||||
}
|
||||
|
||||
@@ -1,72 +1,17 @@
|
||||
package br.com.tasknoteapp.server.util;
|
||||
|
||||
import br.com.tasknoteapp.server.exception.BadAlgorithmException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/** This class provides method to handle UUIDs. */
|
||||
public class UuidUtil {
|
||||
private final UUID namespaceUrl = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
|
||||
|
||||
/**
|
||||
* Generated a unique UUID to a given email.
|
||||
* Generates a cryptographically random UUID for use as an email confirmation token.
|
||||
*
|
||||
* @param email The email to create the UUID.
|
||||
* @return The generated UUID.
|
||||
* @param email The user email (unused; kept for API compatibility).
|
||||
* @return A random UUID.
|
||||
*/
|
||||
public UUID generateEmailUuid(String email) {
|
||||
return generateUuidFromName(namespaceUrl, email.toLowerCase().trim());
|
||||
}
|
||||
|
||||
private UUID generateUuidFromName(UUID namespace, String name) {
|
||||
// SHA-1 digest of namespace UUID + name
|
||||
byte[] namespaceBytes = toBytes(namespace);
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] combined = new byte[namespaceBytes.length + nameBytes.length];
|
||||
System.arraycopy(namespaceBytes, 0, combined, 0, namespaceBytes.length);
|
||||
System.arraycopy(nameBytes, 0, combined, namespaceBytes.length, nameBytes.length);
|
||||
|
||||
byte[] sha1 = sha1(combined);
|
||||
|
||||
// Manipulate bits to make it UUID v5 (version 5, SHA-1)
|
||||
sha1[6] &= 0x0f;
|
||||
sha1[6] |= 0x50;
|
||||
sha1[8] &= 0x3f;
|
||||
sha1[8] |= (byte) 0x80;
|
||||
|
||||
return bytesToUuid(sha1);
|
||||
}
|
||||
|
||||
private byte[] toBytes(UUID uuid) {
|
||||
long msb = uuid.getMostSignificantBits();
|
||||
long lsb = uuid.getLeastSignificantBits();
|
||||
byte[] bytes = new byte[16];
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
bytes[i] = (byte) ((msb >>> (8 * (7 - i))) & 0xFF);
|
||||
bytes[8 + i] = (byte) ((lsb >>> (8 * (7 - i))) & 0xFF);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private byte[] sha1(byte[] input) {
|
||||
try {
|
||||
return java.security.MessageDigest.getInstance("SHA-1").digest(input);
|
||||
} catch (Exception e) {
|
||||
throw new BadAlgorithmException("SHA-1 algorithm not available");
|
||||
}
|
||||
}
|
||||
|
||||
private UUID bytesToUuid(byte[] hash) {
|
||||
long msb = 0;
|
||||
long lsb = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
msb = (msb << 8) | (hash[i] & 0xff);
|
||||
}
|
||||
for (int i = 8; i < 16; i++) {
|
||||
lsb = (lsb << 8) | (hash[i] & 0xff);
|
||||
}
|
||||
return new UUID(msb, lsb);
|
||||
return UUID.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"name": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlServerUntrustedCertificateSqlException",
|
||||
"allDeclaredMethods": true,
|
||||
"allDeclaredConstructors": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,14 +2,14 @@ br:
|
||||
com:
|
||||
tasknote:
|
||||
server:
|
||||
jwt-secret: ${SECURITY_KEY:empty}
|
||||
jwt-secret: ${SECURITY_KEY}
|
||||
target-env: ${TARGET_ENV:development}
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost}
|
||||
logging:
|
||||
level:
|
||||
root: ${ROOT_LOG_LEVEL:INFO}
|
||||
br.com.tasknoteapp: TRACE
|
||||
br.com.tasknoteapp: INFO
|
||||
|
||||
mailgun:
|
||||
api-key: ${MAILGUN_APIKEY:abc123456}
|
||||
@@ -19,7 +19,7 @@ mailgun:
|
||||
server:
|
||||
port: 8585
|
||||
error:
|
||||
include-message: always
|
||||
include-message: never
|
||||
servlet:
|
||||
context-path: ${SERVER_SERVLET_CONTEXT_PATH:/server}
|
||||
spring:
|
||||
@@ -27,7 +27,7 @@ spring:
|
||||
name: tasknote-api
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
password: ${POSTGRES_PASSWORD:default}
|
||||
password: ${POSTGRES_PASSWORD}
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5435}/${POSTGRES_DB:tasknote}
|
||||
username: ${POSTGRES_USER:tasknoteuser}
|
||||
flyway:
|
||||
|
||||
@@ -2,14 +2,14 @@ br:
|
||||
com:
|
||||
tasknote:
|
||||
server:
|
||||
jwt-secret: ${SECURITY_KEY:empty}
|
||||
jwt-secret: ${SECURITY_KEY}
|
||||
target-env: ${TARGET_ENV:development}
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost}
|
||||
logging:
|
||||
level:
|
||||
root: ${ROOT_LOG_LEVEL:INFO}
|
||||
br.com.tasknoteapp: TRACE
|
||||
br.com.tasknoteapp: INFO
|
||||
|
||||
mailgun:
|
||||
api-key: ${MAILGUN_APIKEY:abc123456}
|
||||
@@ -19,7 +19,7 @@ mailgun:
|
||||
server:
|
||||
port: 8585
|
||||
error:
|
||||
include-message: always
|
||||
include-message: never
|
||||
servlet:
|
||||
context-path: ${SERVER_SERVLET_CONTEXT_PATH:/server}
|
||||
spring:
|
||||
@@ -27,7 +27,7 @@ spring:
|
||||
name: tasknote-api
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
password: ${POSTGRES_PASSWORD:default}
|
||||
password: ${POSTGRES_PASSWORD}
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5435}/${POSTGRES_DB:tasknote}
|
||||
username: ${POSTGRES_USER:tasknoteuser}
|
||||
flyway:
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasknote.notes
|
||||
ADD CONSTRAINT chk_notes_description_max_length CHECK (length(description) <= 50000) NOT VALID;
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasknote.tasks RENAME COLUMN done TO completed;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE tasknote.notes
|
||||
ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_archived ON tasknote.notes (archived);
|
||||
@@ -44,7 +44,7 @@ class NoteControllerTest {
|
||||
void getAllNotes_notesFound_shouldSucceed() throws Exception {
|
||||
NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com");
|
||||
NoteResponse note =
|
||||
new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null);
|
||||
new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null, false);
|
||||
|
||||
when(noteService.getAllNotes()).thenReturn(List.of(note));
|
||||
|
||||
@@ -109,7 +109,8 @@ class NoteControllerTest {
|
||||
null,
|
||||
List.of("tag"),
|
||||
false,
|
||||
null);
|
||||
null,
|
||||
false);
|
||||
|
||||
when(noteService.patchNote(noteId, patchRequest)).thenReturn(response);
|
||||
|
||||
@@ -201,7 +202,7 @@ class NoteControllerTest {
|
||||
NoteRequest request = new NoteRequest("Title", "Description", null, List.of("tag"));
|
||||
|
||||
NoteResponse entity = new NoteResponse(1L, request.title(), request.description(),
|
||||
null, null, List.of("tag"), false, null);
|
||||
null, null, List.of("tag"), false, null, false);
|
||||
|
||||
when(noteService.createNote(request)).thenReturn(entity);
|
||||
|
||||
@@ -331,7 +332,8 @@ class NoteControllerTest {
|
||||
final Long noteId = 1L;
|
||||
final String token = "test-token-uuid";
|
||||
NoteResponse response =
|
||||
new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), true, token);
|
||||
new NoteResponse(
|
||||
noteId, "title", "description", null, null, List.of("tag"), true, token, false);
|
||||
|
||||
when(noteService.shareNote(noteId)).thenReturn(response);
|
||||
|
||||
@@ -366,7 +368,8 @@ class NoteControllerTest {
|
||||
void unshareNote_happyPath_shouldSucceed() throws Exception {
|
||||
final Long noteId = 1L;
|
||||
NoteResponse response =
|
||||
new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), false, null);
|
||||
new NoteResponse(
|
||||
noteId, "title", "description", null, null, List.of("tag"), false, null, false);
|
||||
|
||||
when(noteService.unshareNote(noteId)).thenReturn(response);
|
||||
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ class PublicNoteControllerTest {
|
||||
void getSharedNote_happyPath_shouldSucceed() throws Exception {
|
||||
final String token = "test-share-token";
|
||||
NoteResponse response =
|
||||
new NoteResponse(1L, "title", "description", null, null, List.of("tag"), true, token);
|
||||
new NoteResponse(
|
||||
1L, "title", "description", null, null, List.of("tag"), true, token, false);
|
||||
|
||||
when(noteService.getSharedNote(token)).thenReturn(response);
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ class TaskControllerTest {
|
||||
void getAllTasks_tasksFound_shouldSucceed() throws Exception {
|
||||
TaskResponse taskResponse =
|
||||
new TaskResponse(
|
||||
1L, "Desc", false, true, null, null, "Moments ago", List.of("tag"), List.of("http://test.com"));
|
||||
1L, false, "Desc", true, null, null, "Moments ago", List.of("tag"), List.of("http://test.com"));
|
||||
when(taskService.getAllTasks()).thenReturn(List.of(taskResponse));
|
||||
|
||||
mockMvc
|
||||
@@ -54,7 +54,7 @@ class TaskControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(taskResponse.id()))
|
||||
.andExpect(jsonPath("$[0].description").value(taskResponse.description()))
|
||||
.andExpect(jsonPath("$[0].done", Matchers.is(false)))
|
||||
.andExpect(jsonPath("$[0].completed", Matchers.is(false)))
|
||||
.andExpect(jsonPath("$[0].highPriority", Matchers.is(true)))
|
||||
.andExpect(jsonPath("$[0].dueDate", Matchers.nullValue()))
|
||||
.andExpect(jsonPath("$[0].dueDateFmt", Matchers.nullValue()))
|
||||
@@ -101,8 +101,8 @@ class TaskControllerTest {
|
||||
TaskResponse taskResponse =
|
||||
new TaskResponse(
|
||||
taskId,
|
||||
"Desc",
|
||||
false,
|
||||
"Desc",
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
@@ -120,7 +120,7 @@ class TaskControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(taskResponse.id()))
|
||||
.andExpect(jsonPath("$.description").value(taskResponse.description()))
|
||||
.andExpect(jsonPath("$.done", Matchers.is(false)))
|
||||
.andExpect(jsonPath("$.completed", Matchers.is(false)))
|
||||
.andExpect(jsonPath("$.highPriority", Matchers.is(true)))
|
||||
.andExpect(jsonPath("$.dueDate", Matchers.nullValue()))
|
||||
.andExpect(jsonPath("$.dueDateFmt", Matchers.nullValue()))
|
||||
@@ -167,13 +167,13 @@ class TaskControllerTest {
|
||||
void patchTask_happyPath_shouldSucceed() throws Exception {
|
||||
Long taskId = 111L;
|
||||
TaskPatchRequest patchRequest =
|
||||
new TaskPatchRequest("Description patched", false, List.of(), null, true, List.of("tag"));
|
||||
new TaskPatchRequest(false, "Description patched", List.of(), null, true, List.of("tag"));
|
||||
|
||||
TaskResponse taskResponse =
|
||||
new TaskResponse(
|
||||
taskId,
|
||||
"Description patched",
|
||||
false,
|
||||
"Description patched",
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
@@ -186,7 +186,7 @@ class TaskControllerTest {
|
||||
"""
|
||||
{
|
||||
"description": "Description patched",
|
||||
"done": false,
|
||||
"completed": false,
|
||||
"urls": [],
|
||||
"highPriority": true
|
||||
}
|
||||
@@ -209,7 +209,7 @@ class TaskControllerTest {
|
||||
void patchTask_notFound_shouldFail() throws Exception {
|
||||
Long taskId = 118L;
|
||||
TaskPatchRequest patchRequest =
|
||||
new TaskPatchRequest("Description patched", false, List.of(), null, true, List.of("tag"));
|
||||
new TaskPatchRequest(false, "Description patched", List.of(), null, true, List.of("tag"));
|
||||
|
||||
when(taskService.patchTask(taskId, patchRequest)).thenThrow(new TaskNotFoundException());
|
||||
|
||||
@@ -217,7 +217,7 @@ class TaskControllerTest {
|
||||
"""
|
||||
{
|
||||
"description": "Description patched",
|
||||
"done": false,
|
||||
"completed": false,
|
||||
"urls": [],
|
||||
"highPriority": true,
|
||||
"tags": ["tag"]
|
||||
@@ -244,7 +244,7 @@ class TaskControllerTest {
|
||||
"""
|
||||
{
|
||||
"description": "Description patched",
|
||||
"done": false,
|
||||
"completed": false,
|
||||
"urls": [],
|
||||
"highPriority": true
|
||||
}
|
||||
@@ -271,8 +271,8 @@ class TaskControllerTest {
|
||||
TaskResponse taskResponse =
|
||||
new TaskResponse(
|
||||
858L,
|
||||
"Description patched",
|
||||
false,
|
||||
"Description patched",
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
|
||||
@@ -103,7 +103,8 @@ class UserControllerTest {
|
||||
void patchUserInfo_happyPath_shouldSucceed() throws Exception {
|
||||
UserResponse response =
|
||||
new UserResponse(1L, "John", "email@example.com", false, null, null, null, null);
|
||||
UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null, null);
|
||||
UserPatchRequest request =
|
||||
new UserPatchRequest("John Doe", response.email(), null, null, null, null);
|
||||
when(authService.patchUserInfo(request)).thenReturn(response);
|
||||
|
||||
String jsonString =
|
||||
|
||||
@@ -264,9 +264,11 @@ class AuthServiceTest {
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
|
||||
UserPwdLimitEntity limit1 = new UserPwdLimitEntity();
|
||||
limit1.setWhenHappened(LocalDateTime.now().minusMinutes(1));
|
||||
limit1.setWhenHappened(LocalDateTime.now().minusSeconds(30));
|
||||
UserPwdLimitEntity limit2 = new UserPwdLimitEntity();
|
||||
limit2.setWhenHappened(LocalDateTime.now().minusMinutes(1));
|
||||
UserPwdLimitEntity limit3 = new UserPwdLimitEntity();
|
||||
limit3.setWhenHappened(LocalDateTime.now().minusMinutes(2));
|
||||
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
|
||||
.thenReturn(List.of(limit1, limit2, limit3));
|
||||
|
||||
@@ -417,12 +419,15 @@ class AuthServiceTest {
|
||||
existing.setName(null);
|
||||
existing.setEmail(email);
|
||||
existing.setAdmin(false);
|
||||
existing.setPassword("hashedCurrentPassword");
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
|
||||
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
String currentPassword = "currentPw123@";
|
||||
when(passwordEncoder.matches(currentPassword, "hashedCurrentPassword")).thenReturn(true);
|
||||
|
||||
UserPatchRequest patchRequest =
|
||||
new UserPatchRequest("Kong", "newemail@domain.com", null, null, null);
|
||||
new UserPatchRequest("Kong", "newemail@domain.com", null, null, null, currentPassword);
|
||||
UserResponse response = authService.patchUserInfo(patchRequest);
|
||||
|
||||
Assertions.assertNotNull(response);
|
||||
@@ -441,13 +446,17 @@ class AuthServiceTest {
|
||||
existing.setName(null);
|
||||
existing.setEmail(email);
|
||||
existing.setAdmin(false);
|
||||
existing.setPassword("hashedCurrentPassword");
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
|
||||
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
String currentPassword = "currentPw123@";
|
||||
when(passwordEncoder.matches(currentPassword, "hashedCurrentPassword")).thenReturn(true);
|
||||
|
||||
String newPassword = "TestHackedPw@difficult!#:)";
|
||||
UserPatchRequest patchRequest =
|
||||
new UserPatchRequest("Kong", "newemail@domain.com", newPassword, newPassword, "en");
|
||||
new UserPatchRequest(
|
||||
"Kong", "newemail@domain.com", newPassword, newPassword, "en", currentPassword);
|
||||
|
||||
when(authUtil.validatePassword(patchRequest.password())).thenReturn(Optional.empty());
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class HomeServiceTest {
|
||||
.thenReturn(List.of(tag1, tag2, tag3, tag4, tag5, tag6));
|
||||
|
||||
TaskResponse task1 =
|
||||
new TaskResponse(1L, "Task 1", false, false, null, null, null, List.of("tag1"), List.of());
|
||||
new TaskResponse(1L, false, "Task 1", false, null, null, null, List.of("tag1"), List.of());
|
||||
when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1));
|
||||
when(noteService.getAllNotes()).thenReturn(List.of());
|
||||
|
||||
@@ -94,7 +94,7 @@ class HomeServiceTest {
|
||||
when(tagRepository.findAllByUser_idOrderByNameAsc(user.getId())).thenReturn(List.of(tag1));
|
||||
|
||||
TaskResponse task1 =
|
||||
new TaskResponse(1L, "Task 1", false, false, null, null, null, List.of(), List.of());
|
||||
new TaskResponse(1L, false, "Task 1", false, null, null, null, List.of(), List.of());
|
||||
when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1));
|
||||
when(noteService.getAllNotes()).thenReturn(List.of());
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ class NoteServiceTest {
|
||||
|
||||
@Test
|
||||
void deleteNote() {
|
||||
note.setArchived(true);
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user