feat: automate prod and add filter options, and date picker (#355)

* feat: automate prod deployments

issue #338

* fix: string parse

* feat: add date picker and filter options

issue #127

* feat: add tooltip and date picker

* ci: split stage deploy into separate tasks to allow separate releases

* test: add more test cases

* ci: split workflows into client and server

* ci: fix pr server workflow
This commit is contained in:
2025-03-14 19:27:21 -03:00
committed by GitHub
parent b49c6fc470
commit 278ae5c344
17 changed files with 960 additions and 251 deletions
+164
View File
@@ -0,0 +1,164 @@
name: Main Client
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'client/**'
paths-ignore:
- '**.md'
- '*.yml'
jobs:
client-code-checks:
name: Client Code Checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.ref }}
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Clean and Install
run: cd client && npm ci
- name: Build
run: cd client && npm run build
- name: Lint
run: cd client && npm run lint
- name: Unit tests
run: cd client && npm run test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v5.0.0
with:
projectBaseDir: client
args: >
-Dsonar.organization=ricardo-campos-org
-Dsonar.projectKey=ricardo-campos-org_react-typescript-todolist_client
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
-Dsonar.typescript.tsconfigPaths=tsconfig.json
-Dsonar.sources=src/
-Dsonar.exclusions=src/__test__/**
-Dsonar.tests=src/__test__/
-Dsonar.verbose=false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
image-promotion-and-deploy:
name: Image promotion and deployment
runs-on: ubuntu-latest
env:
DEPLOY_DOMAIN: ${{ vars.DEPLOY_DOMAIN }}
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
GHCR_PASSWORD: ${{ secrets.GHCR_PASSWORD }}
API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
CLIENT_APPID: ${{ secrets.PROD_WEB_CLIENT_ID }}
steps:
- uses: docker/login-action@v3
name: Login to GitHub Container Registry
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull image with source tag
run: docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate
- name: Extract PR number using docker inspect
id: inspect
run: |
SOURCE_PR=$(docker inspect ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p')
echo "SOURCE_PR=$SOURCE_PR" >> $GITHUB_ENV
echo "source_pr=$SOURCE_PR" >> $GITHUB_OUTPUT
- name: Re-tag the image
run: docker tag ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate ghcr.io/ricardo-campos-org/react-typescript-todolist/client:prod-${{ steps.inspect.outputs.source_pr }}
- name: Push new tag
run: docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/client:prod-${{ steps.inspect.outputs.source_pr }}
- name: Update image tag to be deployed
uses: nick-fields/retry@v3.0.2
with:
timeout_minutes: 2
max_attempts: 3
command: |
response=$(curl -X POST \
"'${DEPLOY_DOMAIN}/api/application.saveDockerProvider'" \
--max-time 30 \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: ${API_KEY}" \
-d '{
"dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/client:prod-${{ steps.inspect.outputs.source_pr }}",
"applicationId": "${CLIENT_APPID}",
"username": "${GHCR_USERNAME}",
"password": "${GHCR_PASSWORD}",
"registryUrl": "ghcr.io"
}' \
-w "\n%{http_code}" \
-s)
status_code=$(echo "$response" | tail -n1)
echo "Status code: $status_code"
if [ "$status_code" -ge 400 ]; then
body=$(echo "$response" | sed '$d')
echo "Update failed with status code $status_code"
echo "Response body: $body"
exit 1
else
echo "Updated succeeded!"
fi
- name: Trigger Deployment
uses: nick-fields/retry@v3.0.2
with:
timeout_minutes: 2
max_attempts: 3
command: |
# wait 10 secs
sleep 10
response=$(curl -X POST \
"${DEPLOY_DOMAIN}/api/application.deploy" \
--max-time 30 \
-H "accept: application/json" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"applicationId\":\"${CLIENT_APPID}\"}" \
-w "\n%{http_code}" \
-s)
status_code=$(echo "$response" | tail -n1)
echo "Status code: $status_code"
if [ "$status_code" -ge 400 ]; then
body=$(echo "$response" | sed '$d')
echo "Deployment failed with status code $status_code"
echo "Response body: $body"
exit 1
else
echo "Deployment succeeded!"
fi
- name: Verify deployment
run: |
# Wait for deployment to complete
sleep 30
if ! curl -s -f "${{ vars.CLIENT_PROD_URL }}/"; then
echo "Prod environment is not healthy"
exit 1
else
echo "Prod environment is healthy"
fi
@@ -1,51 +1,17 @@
name: Main
name: Main Server
on:
workflow_dispatch:
push:
branches:
- main
workflow_dispatch:
paths:
- 'server/**'
paths-ignore:
- '**.md'
- '*.yml'
jobs:
client-code-checks:
name: Client Code Checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.ref }}
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Clean and Install
run: cd client && npm ci
- name: Build
run: cd client && npm run build
- name: Lint
run: cd client && npm run lint
- name: Unit tests
run: cd client && npm run test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v5.0.0
with:
projectBaseDir: client
args: >
-Dsonar.organization=ricardo-campos-org
-Dsonar.projectKey=ricardo-campos-org_react-typescript-todolist_client
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
-Dsonar.typescript.tsconfigPaths=tsconfig.json
-Dsonar.sources=src/
-Dsonar.exclusions=src/__test__/**
-Dsonar.tests=src/__test__/
-Dsonar.verbose=false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
java-code-checks:
name: Server Code Checks
runs-on: ubuntu-latest
@@ -103,17 +69,6 @@ jobs:
GHCR_PASSWORD: ${{ secrets.GHCR_PASSWORD }}
API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
SERVER_APPID: ${{ secrets.PROD_API_CLIENT_ID }}
CLIENT_APPID: ${{ secrets.PROD_WEB_CLIENT_ID }}
strategy:
matrix:
name: [server, client]
include:
- name: server
health_check_url: "${{ vars.API_PROD_URL }}/actuator/health"
app_id: ${SERVER_APPID}
- name: client
health_check_url: "${{ vars.CLIENT_PROD_URL }}/"
app_id: ${CLIENT_APPID}
steps:
- uses: docker/login-action@v3
name: Login to GitHub Container Registry
@@ -123,20 +78,20 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull image with source tag
run: docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/${{ matrix.name }}:candidate
run: docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/server:candidate
- name: Extract PR number using docker inspect
id: inspect
run: |
SOURCE_PR=$(docker inspect ghcr.io/ricardo-campos-org/react-typescript-todolist/${{ matrix.name }}:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p')
SOURCE_PR=$(docker inspect ghcr.io/ricardo-campos-org/react-typescript-todolist/server:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p')
echo "SOURCE_PR=$SOURCE_PR" >> $GITHUB_ENV
echo "source_pr=$SOURCE_PR" >> $GITHUB_OUTPUT
- name: Re-tag the image
run: docker tag ghcr.io/ricardo-campos-org/react-typescript-todolist/${{ matrix.name }}:candidate ghcr.io/ricardo-campos-org/react-typescript-todolist/${{ matrix.name }}:prod-${{ steps.inspect.outputs.source_pr }}
run: docker tag ghcr.io/ricardo-campos-org/react-typescript-todolist/server:candidate ghcr.io/ricardo-campos-org/react-typescript-todolist/server:prod-${{ steps.inspect.outputs.source_pr }}
- name: Push new tag
run: docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/${{ matrix.name }}:prod-${{ steps.inspect.outputs.source_pr }}
run: docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/server:prod-${{ steps.inspect.outputs.source_pr }}
- name: Update image tag to be deployed
uses: nick-fields/retry@v3.0.2
@@ -145,16 +100,18 @@ jobs:
max_attempts: 3
command: |
response=$(curl -X POST \
"${DEPLOY_DOMAIN}/api/application.saveDockerProvider" \
"'${DEPLOY_DOMAIN}/api/application.saveDockerProvider'" \
--max-time 30 \
-H "accept: application/json" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"applicationId\":\"${{ matrix.app_id }}\"" \
",\"dockerImage\": \"ghcr.io/ricardo-campos-org/react-typescript-todolist/${{ matrix.name }}:prod-${{ steps.inspect.outputs.source_pr }}\"" \
",\"username\": \"${GHCR_USERNAME}\"" \
",\"password\": \"${GHCR_PASSWORD}\"" \
",\"registryUrl\": \"ghcr.io\"}" \
-H "x-api-key: ${API_KEY}" \
-d '{
"dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/server:prod-${{ steps.inspect.outputs.source_pr }}",
"applicationId": "${SERVER_APPID}",
"username": "${GHCR_USERNAME}",
"password": "${GHCR_PASSWORD}",
"registryUrl": "ghcr.io"
}' \
-w "\n%{http_code}" \
-s)
@@ -186,7 +143,7 @@ jobs:
-H "accept: application/json" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"applicationId\":\"${{ matrix.app_id }}\"}" \
-d "{\"applicationId\":\"${SERVER_APPID}\"}" \
-w "\n%{http_code}" \
-s)
@@ -208,7 +165,7 @@ jobs:
# Wait for deployment to complete
sleep 30
if ! curl -s -f "${{ matrix.health_check_url }}"; then
if ! curl -s -f "${{ vars.API_PROD_URL }}/actuator/health"; then
echo "Prod environment is not healthy"
exit 1
else
+141
View File
@@ -0,0 +1,141 @@
name: PR Client
on:
pull_request:
paths:
- 'client/**'
concurrency:
group: client-${{ github.event.number }}
cancel-in-progress: true
jobs:
client-code-checks:
name: Client Code Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
name: Set up Node
with:
node-version: '22'
- name: Clean and Install
run: cd client && npm ci
- name: Build
run: cd client && npm run build
- name: Lint
run: cd client && npm run lint
- name: Unit tests
run: cd client && npm run test:coverage
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v5.0.0
with:
projectBaseDir: client
args: >
-Dsonar.organization=ricardo-campos-org
-Dsonar.projectKey=ricardo-campos-org_react-typescript-todolist_client
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
-Dsonar.typescript.tsconfigPaths=tsconfig.json
-Dsonar.sources=src/
-Dsonar.exclusions=src/__test__/**
-Dsonar.tests=src/__test__/
-Dsonar.verbose=false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
client-docker-build:
name: Build Client Docker image
runs-on: ubuntu-latest
needs: client-code-checks
env:
VITE_BUILD: client:${{ github.event.number }}
VITE_BACKEND_SERVER: ${{ secrets.SERVER_ADDRESS }}/server
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
name: Set up Docker Buildx
- uses: docker/login-action@v3
name: Login to GitHub Container Registry
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d-%H%M%S')" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: ./client
tags: ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate
build-args: |
VITE_BUILD=v${{ github.event.number }}-${{ steps.date.outputs.date }}
SOURCE_PR=v${{ github.event.number }}-${{ github.run_id }}-${{ steps.date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
client-stage-deployments:
name: Deploy Changes to Stage
runs-on: ubuntu-latest
needs: client-docker-build
if: github.event.pull_request.user.login == 'rmcampos' && github.event_name == 'pull_request'
env:
DEPLOY_DOMAIN: ${{ vars.DEPLOY_DOMAIN }}
API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
CLIENT_APPID: ${{ secrets.STAGE_WEB_CLIENT_ID }}
steps:
- name: Pre-deployment check
run: |
if ! curl -s -f "${{ vars.CLIENT_STAGE_URL }}"; then
echo "Stage environment is not healthy"
else
echo "Stage environment is healthy"
fi
- name: Trigger Deployment
uses: nick-fields/retry@v3.0.2
with:
timeout_minutes: 2
max_attempts: 3
command: |
response=$(curl -X POST \
"${DEPLOY_DOMAIN}/api/application.deploy" \
--max-time 30 \
-H "accept: application/json" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"applicationId\":\"${CLIENT_APPID}\"}" \
-w "\n%{http_code}" \
-s)
status_code=$(echo "$response" | tail -n1)
echo "Status code: $status_code"
if [ "$status_code" -ge 400 ]; then
body=$(echo "$response" | sed '$d')
echo "Deployment failed with status code $status_code"
echo "Response body: $body"
exit 1
else
echo "Deployment succeeded!"
fi
- name: Verify deployment
run: |
# Wait for deployment to complete
sleep 30
if ! curl -s -f "${{ vars.CLIENT_STAGE_URL }}"; then
echo "Stage environment is not healthy"
exit 1
else
echo "Stage environment is healthy"
fi
@@ -1,111 +1,16 @@
name: PR
name: PR Server
on:
pull_request:
paths:
- 'client/**'
concurrency:
group: ${{ github.event.number }}
group: server-${{ github.event.number }}
cancel-in-progress: true
jobs:
# JOB to run change detection
changes:
name: Check changes
runs-on: ubuntu-latest
# Required permissions
permissions:
pull-requests: read
# Set job outputs to values from filter step
outputs:
server: ${{ steps.filter.outputs.server }}
client: ${{ steps.filter.outputs.client }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
server:
- 'server/**'
client:
- 'client/**'
client-code-checks:
needs: changes
if: ${{ needs.changes.outputs.client == 'true' }}
name: Client Code Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
name: Set up Node
with:
node-version: '22'
- name: Clean and Install
run: cd client && npm ci
- name: Build
run: cd client && npm run build
- name: Lint
run: cd client && npm run lint
- name: Unit tests
run: cd client && npm run test:coverage
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v5.0.0
with:
projectBaseDir: client
args: >
-Dsonar.organization=ricardo-campos-org
-Dsonar.projectKey=ricardo-campos-org_react-typescript-todolist_client
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
-Dsonar.typescript.tsconfigPaths=tsconfig.json
-Dsonar.sources=src/
-Dsonar.exclusions=src/__test__/**
-Dsonar.tests=src/__test__/
-Dsonar.verbose=false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
client-docker-build:
name: Build Client Docker image
runs-on: ubuntu-latest
needs: client-code-checks
env:
VITE_BUILD: client:${{ github.event.number }}
VITE_BACKEND_SERVER: ${{ secrets.SERVER_ADDRESS }}/server
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
name: Set up Docker Buildx
- uses: docker/login-action@v3
name: Login to GitHub Container Registry
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d-%H%M%S')" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: ./client
tags: ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate
build-args: |
VITE_BUILD=v${{ github.event.number }}-${{ steps.date.outputs.date }}
SOURCE_PR=v${{ github.event.number }}-${{ github.run_id }}-${{ steps.date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
java-code-checks:
needs: changes
if: ${{ needs.changes.outputs.server == 'true' }}
name: Server Code Checks
runs-on: ubuntu-latest
steps:
@@ -187,30 +92,20 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
stage-deployments:
java-stage-deployments:
name: Deploy Changes to Stage
runs-on: ubuntu-latest
needs: [java-docker-build, client-docker-build]
needs: java-docker-build
if: github.event.pull_request.user.login == 'rmcampos' && github.event_name == 'pull_request'
env:
DEPLOY_DOMAIN: ${{ vars.DEPLOY_DOMAIN }}
API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
SERVER_APPID: ${{ secrets.STAGE_API_CLIENT_ID }}
CLIENT_APPID: ${{ secrets.STAGE_WEB_CLIENT_ID }}
strategy:
matrix:
name: [server, client]
include:
- name: server
health_check_url: "${{ vars.API_STAGE_URL }}/actuator/health"
app_id: ${SERVER_APPID}
- name: client
health_check_url: "${{ vars.CLIENT_STAGE_URL }}/"
app_id: ${CLIENT_APPID}
steps:
- name: Pre-deployment check
run: |
if ! curl -s -f "${{ matrix.health_check_url }}"; then
if ! curl -s -f "${{ vars.API_STAGE_URL }}/actuator/health"; then
echo "Stage environment is not healthy"
else
echo "Stage environment is healthy"
@@ -228,7 +123,7 @@ jobs:
-H "accept: application/json" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"applicationId\":\"${{ matrix.app_id }}\"}" \
-d "{\"applicationId\":\"${SERVER_APPID}\"}" \
-w "\n%{http_code}" \
-s)
@@ -250,9 +145,9 @@ jobs:
# Wait for deployment to complete
sleep 30
if ! curl -s -f "${{ matrix.health_check_url }}"; then
if ! curl -s -f "${{ vars.API_STAGE_URL }}/actuator/health"; then
echo "Stage environment is not healthy"
exit 1
else
echo "Stage environment is healthy"
fi
fi
+159
View File
@@ -20,6 +20,7 @@
"react-bootstrap": "^2.10.9",
"react-bootstrap-icons": "^1.11.5",
"react-charts": "^3.0.0-beta.57",
"react-datepicker": "^8.2.1",
"react-dom": "^19.0.0",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
@@ -1095,6 +1096,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.5",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.5.tgz",
"integrity": "sha512-BX3jKxo39Ba05pflcQmqPPwc0qdNsdNi/eweAFtoIdrJWNen2sVEWMEac3i6jU55Qfx+lOcdMNKYn2CtWmlnOQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3386,6 +3440,15 @@
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3659,6 +3722,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -7735,6 +7808,21 @@
"@types/react": "^17.0.0"
}
},
"node_modules/react-datepicker": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.2.1.tgz",
"integrity": "sha512-1pyALWM9mTZ7DG7tfcApwBy2kkld9Kz/EI++LhPnoXJAASbvuq6fdsDfkoB3q1JrxF7vhghVmQ759H/rOwUNNw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
@@ -8793,6 +8881,12 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@@ -10372,6 +10466,46 @@
"levn": "^0.4.1"
}
},
"@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"requires": {
"@floating-ui/utils": "^0.2.9"
}
},
"@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"requires": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"@floating-ui/react": {
"version": "0.27.5",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.5.tgz",
"integrity": "sha512-BX3jKxo39Ba05pflcQmqPPwc0qdNsdNi/eweAFtoIdrJWNen2sVEWMEac3i6jU55Qfx+lOcdMNKYn2CtWmlnOQ==",
"requires": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
"tabbable": "^6.0.0"
}
},
"@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"requires": {
"@floating-ui/dom": "^1.0.0"
}
},
"@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="
},
"@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -11791,6 +11925,11 @@
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -12002,6 +12141,11 @@
"is-data-view": "^1.0.1"
}
},
"date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
},
"debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -14748,6 +14892,16 @@
}
}
},
"react-datepicker": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.2.1.tgz",
"integrity": "sha512-1pyALWM9mTZ7DG7tfcApwBy2kkld9Kz/EI++LhPnoXJAASbvuq6fdsDfkoB3q1JrxF7vhghVmQ759H/rOwUNNw==",
"requires": {
"@floating-ui/react": "^0.27.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0"
}
},
"react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
@@ -15466,6 +15620,11 @@
"tslib": "^2.6.2"
}
},
"tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
},
"tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+1
View File
@@ -23,6 +23,7 @@
"react-bootstrap": "^2.10.9",
"react-bootstrap-icons": "^1.11.5",
"react-charts": "^3.0.0-beta.57",
"react-datepicker": "^8.2.1",
"react-dom": "^19.0.0",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
+15 -9
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { test, vi } from 'vitest';
import App from '../App';
import { render } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import AuthContext from '../context/AuthContext';
import authContextMock from './__mocks__/authContextMock';
import SidebarContext from '../context/SidebarContext';
@@ -11,12 +11,18 @@ const sidebarContextMock = {
setNewPage: vi.fn()
};
test('Renders the app', () => {
render(
<AuthContext.Provider value={authContextMock}>
<SidebarContext.Provider value={sidebarContextMock}>
<App />
</SidebarContext.Provider>
</AuthContext.Provider>
);
vi.mock('react-charts', () => ({
Chart: ({ options }) => <div data-testid="mocked-chart">Mocked Chart</div>
}));
test('Renders the app', async () => {
await act(async () => {
render(
<AuthContext.Provider value={authContextMock}>
<SidebarContext.Provider value={sidebarContextMock}>
<App />
</SidebarContext.Provider>
</AuthContext.Provider>
);
});
});
@@ -4,13 +4,23 @@ import { describe, expect, it } from 'vitest';
import TaskTimeLeft from '../../components/TaskTimeLeft';
describe('TaskTimeLeft Component', () => {
const renderComponent = (done: boolean) => {
return render(
<TaskTimeLeft
text="2 days left"
done={done}
tooltip="2025-03-20"
/>
);
};
it('should render the TaskTimeLeft component with text when task is not done', () => {
const { getByText } = render(<TaskTimeLeft text="2 days left" done={false} />);
const { getByText } = renderComponent(false);
expect(getByText('2 days left')).toBeDefined();
});
it('should not render the TaskTimeLeft component when task is done', () => {
const { queryByText } = render(<TaskTimeLeft text="2 days left" done={true} />);
const { queryByText } = renderComponent(true);
expect(queryByText('2 days left')).toBeNull();
});
});
@@ -146,20 +146,26 @@ describe('AuthProvider', () => {
vi.spyOn(api, 'putJSON').mockResolvedValue(fakeResponse);
const { getByTestId } = render(
<AuthProvider>
<ConsumerComponent />
</AuthProvider>
);
const user = userEvent.setup();
let getByTestIdFunction;
await act(async () => {
userEvent.click(getByTestId('register'));
const { getByTestId } = render(
<AuthProvider>
<ConsumerComponent />
</AuthProvider>
);
getByTestIdFunction = getByTestId;
});
await waitFor(() => expect(getByTestIdFunction('register')).toBeDefined());
await user.click(getByTestIdFunction('register'));
await waitFor(() =>
expect(getByTestId('signed').textContent).toBe('true')
expect(getByTestIdFunction('signed').textContent).toBe('true')
);
expect(getByTestId('user').textContent).toBe('New User');
expect(getByTestIdFunction('user').textContent).toBe('New User');
expect(localStorage.getItem(API_TOKEN)).toBe('register-token');
expect(localStorage.getItem(USER_DATA)).not.toBeNull();
});
@@ -261,16 +267,23 @@ describe('AuthProvider', () => {
// Store API_TOKEN so that fetchCurrentSession runs the refresh logic.
localStorage.setItem(API_TOKEN, 'dummy');
const { getByTestId } = render(
<AuthProvider>
<ConsumerComponent />
</AuthProvider>
);
const user = userEvent.setup();
await waitFor(() => {
userEvent.click(getByTestId('checkCurrentAuthUser'));
let getByTestIdFunction;
await act(async () => {
const { getByTestId } = render(
<AuthProvider>
<ConsumerComponent />
</AuthProvider>
);
getByTestIdFunction = getByTestId;
});
// Wait for any initial renders to complete
await waitFor(() => expect(getByTestIdFunction('checkCurrentAuthUser')).toBeDefined());
await user.click(getByTestIdFunction('checkCurrentAuthUser'));
await waitFor(() =>
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token')
);
+121 -5
View File
@@ -1,20 +1,136 @@
import React from 'react';
import { MemoryRouter } from 'react-router';
import { render, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TaskResponse } from '../../types/TaskResponse';
import api from '../../api-service/api';
import Task from '../../views/Task';
import '../../i18n';
vi.mock('../../api-service/api');
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
changeLanguage: vi.fn(),
language: 'en',
},
t: (key: string) => key,
}),
initReactI18next: {
type: '3rdParty',
init: vi.fn(),
},
I18nextProvider: ({ children }: any) => children,
}));
const mockTask: TaskResponse[] = [
{
id: 1,
description: 'Task_one',
done: false,
highPriority: false,
dueDate: '',
dueDateFmt: '',
lastUpdate: '1 day ago',
tag: 'test1',
urls: ['http://test.copm']
},
{
id: 2,
description: 'Task_two',
done: false,
highPriority: true,
dueDate: '2025-12-31',
dueDateFmt: '7 months left',
lastUpdate: '2 days ago',
tag: 'test2',
urls: ['http://test.copm']
},
{
id: 3,
description: 'Task_three',
done: true,
highPriority: true,
dueDate: '',
dueDateFmt: '',
lastUpdate: '3 days ago',
tag: 'test3',
urls: ['http://test.copm']
}
];
describe('Renders the task view', () => {
it('should render the task view', async() => {
render(
beforeEach(() => {
vi.clearAllMocks();
});
const renderTask = () => {
return render(
<MemoryRouter>
<Task />
</MemoryRouter>
);
};
it('should render the Task view and load tasks', async () => {
vi.spyOn(api, 'getJSON').mockResolvedValue(mockTask);
renderTask();
await waitFor(() => {
expect(document.querySelector('.main-margin')).toBeDefined();
// normal priority task
expect(screen.getByText('Task_one')).toBeDefined();
expect(screen.queryByTestId('task-title-bell-Task_one')).toBeNull();
expect(Array.from(screen.getByTestId('task-title-text-Task_one').classList).join(' ')).toBe('poppins-semibold');
// high priority task
expect(screen.getByText('Task_two')).toBeDefined();
expect(screen.getByTestId('task-title-bell-Task_two')).toBeDefined();
expect(Array.from(screen.getByTestId('task-title-text-Task_two').classList).join(' ')).toBe('ms-2 poppins-semibold');
// done task
expect(screen.getByText('Task_three')).toBeDefined();
expect(screen.getByTestId('task-title-check-Task_three')).toBeDefined();
expect(Array.from(screen.getByTestId('task-title-text-Task_three').classList).join(' ')).toBe('text-strike ms-2 poppins-semibold');
});
});
it('should filter notes based on input text', async () => {
vi.spyOn(api, 'getJSON').mockResolvedValue(mockTask);
renderTask();
await waitFor(() => {
expect(screen.getByText('Task_one')).toBeDefined();
expect(screen.getByText('Task_two')).toBeDefined();
expect(screen.getByText('Task_three')).toBeDefined();
});
// filter by text 'one'
fireEvent.change(screen.getByPlaceholderText('Filter tasks'), { target: { value: 'one' } });
expect(screen.getByText('Task_one')).toBeDefined();
expect(screen.queryByText('Task_two')).toBeNull();
expect(screen.queryByText('Task_three')).toBeNull();
});
it('should filter notes based on radio change', async () => {
vi.spyOn(api, 'getJSON').mockResolvedValue(mockTask);
renderTask();
await waitFor(() => {
expect(screen.getByText('Task_one')).toBeDefined();
expect(screen.getByText('Task_two')).toBeDefined();
expect(screen.getByText('Task_three')).toBeDefined();
});
// filter only completed
fireEvent.click(screen.getByLabelText('Completed'));
expect(screen.queryByText('Task_one')).toBeNull();
expect(screen.queryByText('Task_two')).toBeNull();
expect(screen.getByText('Task_three')).toBeDefined();
});
});
@@ -0,0 +1,30 @@
/* custom-datepicker.css */
.react-datepicker__input-container input {
font-size: 16px; /* Prevents iOS zoom on focus */
width: 100%;
border-radius: 8px;
border: 1px solid #ced4da;
}
.react-datepicker-popper {
z-index: 9999 !important;
}
.react-datepicker__day {
margin: 0.2rem;
padding: 0.5rem;
border-radius: 0.3rem;
}
/* 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;
}
}
+52 -11
View File
@@ -1,6 +1,11 @@
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 'react-datepicker/dist/react-datepicker.css';
import './custom-datepicker.css';
import { MiddlewareReturn } from '@floating-ui/core';
import { MiddlewareState } from '@floating-ui/dom';
type IconName = keyof typeof Icons;
@@ -11,8 +16,10 @@ interface Props {
type?: string;
name: string;
placeholder?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
value?: string;
valueDate?: Date | null;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onChangeDate?: (date: Date | null) => void;
data_testid?: string;
}
@@ -74,15 +81,49 @@ function FormInput(props: React.PropsWithChildren<Props>): React.ReactNode {
<InputGroup.Text>
<Icon />
</InputGroup.Text>
<Form.Control
required={props.required}
type={formType}
name={props.name}
placeholder={props.placeholder ? props.placeholder : ''}
value={props.value}
onChange={props.onChange}
data-testid={props.data_testid}
/>
{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"
popperPlacement="bottom"
popperModifiers={[
{
name: 'preventOverflow',
options: {
enabled: true,
boundariesElement: 'viewport'
},
fn: function (state: MiddlewareState): MiddlewareReturn | Promise<MiddlewareReturn> {
return state;
}
}
]}
// Mobile-friendly options
withPortal
showYearDropdown
showMonthDropdown
dropdownMode="select"
/>
)
: (
<Form.Control
required={props.required}
type={formType}
name={props.name}
placeholder={props.placeholder ? props.placeholder : ''}
value={props?.value}
onChange={props.onChange}
data-testid={props.data_testid}
/>
)}
</InputGroup>
</Form.Group>
</Col>
+34 -6
View File
@@ -1,28 +1,56 @@
import React from 'react';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { CalendarCheck } from 'react-bootstrap-icons';
interface Props {
text: string;
done: boolean;
tooltip: string;
}
/**
* Renders the TaskTimeLeft component if the task is not done, displaying
* a calendar icon and the time left for the task.
*
* @param {Props.done} props.done - Boolean value indicating if the task is done.
* @param {Props.text} props.text - 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 {string} props.tooltip - The string representation of a Date instance.
* @returns
*/
function TaskTimeLeft(props: Props): React.ReactNode | null {
function TaskTimeLeft(props: React.PropsWithChildren<Props>): React.ReactNode | null {
const tooltipDate = props.tooltip.length > 0
? new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
}).format(new Date(props.tooltip))
: '';
return props.done
? null
: (
<div className="d-block">
<CalendarCheck />
<span className="ms-2 poppins-regular">
{props.text}
</span>
{props.tooltip.length > 0 && (
<OverlayTrigger
placement="right"
overlay={(
<Tooltip id="tooltip-right">
{tooltipDate}
</Tooltip>
)}
>
<span className="ms-2 poppins-regular">
{props.text}
</span>
</OverlayTrigger>
)}
{props.tooltip.length === 0 && (
<span className="ms-2 poppins-regular">
{props.text}
</span>
)}
</div>
);
}
+13 -7
View File
@@ -20,26 +20,32 @@ interface Props {
function TaskTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
if (props.highPriority) {
return (
<span className="task-title-icon">
<span className="task-title-icon" data-testid={`task-title-container-${props.title}`}>
{props.done
? (
<Check2Circle />
<Check2Circle data-testid={`task-title-check-${props.title}`} />
)
: (
<Bell />
<Bell data-testid={`task-title-bell-${props.title}`} />
)}
<span className={`${props.done ? 'text-strike' : ''} ms-2 poppins-semibold`}>
<span
className={`${props.done ? 'text-strike' : ''} ms-2 poppins-semibold`}
data-testid={`task-title-text-${props.title}`}
>
{props.title}
</span>
</span>
);
}
return (
<span className="task-title-icon">
<span className="task-title-icon" data-testid={`task-title-container-${props.title}`}>
{props.done && (
<Check2Circle />
<Check2Circle data-testid={`task-title-check-${props.title}`} />
)}
<span className={`${props.done ? 'ms-2 text-strike' : ''} poppins-semibold`}>
<span
className={`${props.done ? 'ms-2 text-strike' : ''} poppins-semibold`}
data-testid={`task-title-text-${props.title}`}
>
{props.title}
</span>
</span>
+68 -6
View File
@@ -19,9 +19,9 @@ import TaskTimeLeft from '../../components/TaskTimeLeft';
import TaskUrl from '../../components/TaskUrl';
import TaskTag from '../../components/TaskTag';
import TaskTitle from '../../components/TaskTitle';
import './style.css';
import ContentHeader from '../../components/ContentHeader';
import AlertError from '../../components/AlertError';
import './style.css';
/**
* The Task component is a view that displays a list of tasks.
@@ -33,6 +33,7 @@ function Task(): React.ReactNode {
const [tasks, setTasks] = useState<TaskResponse[]>([]);
const [savedTasks, setSavedTasks] = useState<TaskResponse[]>([]);
const [filterText, setFilterText] = useState<string>('');
const [selectedOption, setSelectedOption] = useState<string>('option1');
const { i18n, t } = useTranslation();
/**
@@ -64,6 +65,7 @@ function Task(): React.ReactNode {
const translated = translateTaskResponse(tasksFetched, i18n.language);
setSavedTasks([...translated]);
setTasks([...translated]);
setSelectedOption('option1');
}
catch (e) {
handleError(e);
@@ -107,24 +109,38 @@ function Task(): React.ReactNode {
/**
* Filter tasks by a given text.
*/
const filterTasks = (text: string): void => {
const filterTasks = (text: string, radioBtnFilter?: string): void => {
setFilterText(text);
if (!text) {
if (!text && !radioBtnFilter) {
setTasks([...savedTasks]);
return;
}
const filteredTasks = savedTasks.filter((task: TaskResponse) => {
let filteredTasks = savedTasks.filter((task: TaskResponse) => {
const shouldFilter = task.description.toLowerCase().includes(text.toLowerCase())
|| task.tag.toLowerCase().includes(text.toLowerCase())
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
return shouldFilter;
});
const pending = radioBtnFilter === 'option2';
const completed = radioBtnFilter === 'option3';
if (pending) {
filteredTasks = filteredTasks.filter((task: TaskResponse) => !task.done);
}
else if (completed) {
filteredTasks = filteredTasks.filter((task: TaskResponse) => task.done);
}
setTasks([...filteredTasks]);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setSelectedOption(e.target.value);
filterTasks(filterText, e.target.value);
};
useEffect(() => {
loadTasks();
}, []);
@@ -150,7 +166,7 @@ function Task(): React.ReactNode {
name="search_term"
placeholder="Filter tasks"
value={filterText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => filterTasks(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => filterTasks(e.target.value, selectedOption)}
/>
</Col>
<Col xs={3}>
@@ -164,6 +180,48 @@ function Task(): React.ReactNode {
</Col>
</Row>
<Row>
<Col>
<Form className="mt-3 ms-1">
<div className="d-flex gap-3">
<Form.Check
inline
type="radio"
label="All tasks"
name="radioGroup"
id="radio1"
value="option1"
checked={selectedOption === 'option1'}
onChange={handleChange}
className="custom-radio-button"
/>
<Form.Check
inline
type="radio"
label="Pending"
name="radioGroup"
id="radio2"
value="option2"
checked={selectedOption === 'option2'}
onChange={handleChange}
className="custom-radio-button"
/>
<Form.Check
inline
type="radio"
label="Completed"
name="radioGroup"
id="radio3"
value="option3"
checked={selectedOption === 'option3'}
onChange={handleChange}
className="custom-radio-button"
/>
</div>
</Form>
</Col>
</Row>
<Row className="mt-3">
{tasks.map((task: TaskResponse) => (
<Col xs={12} key={task.id.toString()}>
@@ -204,7 +262,11 @@ function Task(): React.ReactNode {
</Row>
{task.dueDateFmt && (
<TaskTimeLeft text={task.dueDateFmt} done={task.done} />
<TaskTimeLeft
text={task.dueDateFmt}
done={task.done}
tooltip={task.dueDate}
/>
)}
{task.urls.length > 0 && (
<TaskUrl url={task.urls[0]} />
+76 -1
View File
@@ -67,4 +67,79 @@
.task-card-footer {
background-color: #fff !important;
border-top: #fff !important;
}
}
/* Radio Buttons */
/* Container styling */
.radio-group-container {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
/* Label styling */
.radio-group-label {
font-weight: 600;
color: #495057;
margin-bottom: 1rem;
font-size: 1.1rem;
}
/* Radio buttons wrapper */
.radio-buttons-wrapper {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
/* Custom radio button styling */
.custom-radio-button .form-check-input {
width: 1.2rem;
height: 1.2rem;
margin-top: 0.2rem;
cursor: pointer;
border: 2px solid #6c757d;
}
.custom-radio-button .form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
.custom-radio-button .form-check-input:focus {
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
border-color: #0d6efd;
}
.custom-radio-button .form-check-label {
font-size: 1rem;
padding-left: 0.3rem;
cursor: pointer;
color: #495057;
transition: color 0.2s;
}
.custom-radio-button:hover .form-check-label {
color: #0d6efd;
}
/* Selected option text */
.selected-option {
font-weight: 500;
color: #0d6efd;
margin-top: 1rem;
padding: 0.5rem;
background-color: rgba(13, 110, 253, 0.1);
border-radius: 4px;
display: inline-block;
}
/* Responsive adjustments */
@media (max-width: 576px) {
.radio-buttons-wrapper {
flex-direction: column;
gap: 0.75rem;
}
}
+14 -9
View File
@@ -33,7 +33,7 @@ function TaskAdd(): React.ReactNode {
const [taskUrl, setTaskUrl] = useState<string>('');
const [taskDone, setTaskDone] = useState<boolean>(false);
const [action, setAction] = useState<TaskAction>('add');
const [dueDate, setDueDate] = useState<string>('');
const [dueDate, setDueDate] = useState<Date | null>(null);
const [highPriority, setHighPriority] = useState<boolean>(false);
const [tag, setTag] = useState<string>('');
const { i18n, t } = useTranslation();
@@ -97,7 +97,7 @@ function TaskAdd(): React.ReactNode {
setTaskDescription('');
setTaskDone(false);
setTaskUrl('');
setDueDate('');
setDueDate(null);
setHighPriority(false);
setTag('');
@@ -121,11 +121,16 @@ function TaskAdd(): React.ReactNode {
return;
}
let dueDateFormatted: string = '';
if (dueDate) {
dueDateFormatted = dueDate.toISOString().substring(0, 10);
}
if (action === 'add') {
const addPayload: TaskNoteRequest = {
description: taskDescription.trim(),
highPriority: highPriority,
dueDate: dueDate || '',
dueDate: dueDateFormatted,
tag: tag,
urls: taskUrl ? [taskUrl] : []
};
@@ -143,7 +148,7 @@ function TaskAdd(): React.ReactNode {
description: taskDescription.trim(),
done: taskDone,
highPriority: highPriority,
dueDate: dueDate || '',
dueDate: dueDateFormatted,
dueDateFmt: '',
lastUpdate: '',
tag: tag,
@@ -171,7 +176,7 @@ function TaskAdd(): React.ReactNode {
setTaskUrl(taskToEdit.urls.length ? taskToEdit.urls[0] : '');
setTaskDone(taskToEdit.done);
if (taskToEdit.dueDateFmt) {
setDueDate(taskToEdit.dueDate);
setDueDate(new Date(taskToEdit.dueDate));
}
setHighPriority(taskToEdit.highPriority);
if (taskToEdit.tag) {
@@ -247,12 +252,12 @@ function TaskAdd(): React.ReactNode {
labelText={t('task_form_duedate_label')}
iconName="CalendarCheck"
required={false}
type="text"
type="date"
name="dueDate"
placeholder={t('task_form_duedate_placeholder')}
value={dueDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setDueDate(e.target.value);
valueDate={dueDate}
onChangeDate={(date: Date | null) => {
setDueDate(date);
}}
/>