From 278ae5c3447520da2f6f085a677e4633427a0e58 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Fri, 14 Mar 2025 15:27:21 -0700 Subject: [PATCH] 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 --- .github/workflows/main-client.yml | 164 ++++++++++++++++++ .../workflows/{main.yml => main-server.yml} | 87 +++------- .github/workflows/pr-client.yml | 141 +++++++++++++++ .github/workflows/{pr.yml => pr-server.yml} | 125 ++----------- client/package-lock.json | 159 +++++++++++++++++ client/package.json | 1 + client/src/__test__/App.test.tsx | 24 ++- .../__test__/components/TaskTimeLeft.test.tsx | 14 +- .../__test__/context/AuthProvider.test.tsx | 43 +++-- client/src/__test__/views/Task.test.tsx | 126 +++++++++++++- .../FormInput/custom-datepicker.css | 30 ++++ client/src/components/FormInput/index.tsx | 63 +++++-- client/src/components/TaskTimeLeft/index.tsx | 40 ++++- client/src/components/TaskTitle/index.tsx | 20 ++- client/src/views/Task/index.tsx | 74 +++++++- client/src/views/Task/style.css | 77 +++++++- client/src/views/TaskAdd/index.tsx | 23 ++- 17 files changed, 960 insertions(+), 251 deletions(-) create mode 100644 .github/workflows/main-client.yml rename .github/workflows/{main.yml => main-server.yml} (64%) create mode 100644 .github/workflows/pr-client.yml rename .github/workflows/{pr.yml => pr-server.yml} (54%) create mode 100644 client/src/components/FormInput/custom-datepicker.css diff --git a/.github/workflows/main-client.yml b/.github/workflows/main-client.yml new file mode 100644 index 0000000..2777e1a --- /dev/null +++ b/.github/workflows/main-client.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main-server.yml similarity index 64% rename from .github/workflows/main.yml rename to .github/workflows/main-server.yml index 561fca9..6a5bda1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main-server.yml @@ -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 diff --git a/.github/workflows/pr-client.yml b/.github/workflows/pr-client.yml new file mode 100644 index 0000000..1e6d371 --- /dev/null +++ b/.github/workflows/pr-client.yml @@ -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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr-server.yml similarity index 54% rename from .github/workflows/pr.yml rename to .github/workflows/pr-server.yml index 8643372..b89214b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr-server.yml @@ -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 \ No newline at end of file + fi diff --git a/client/package-lock.json b/client/package-lock.json index 373f0b9..0dd0842 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 5556375..b1ba7a1 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/__test__/App.test.tsx b/client/src/__test__/App.test.tsx index 3e0f4b3..1ffeba7 100644 --- a/client/src/__test__/App.test.tsx +++ b/client/src/__test__/App.test.tsx @@ -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( - - - - - - ); +vi.mock('react-charts', () => ({ + Chart: ({ options }) =>
Mocked Chart
+})); + +test('Renders the app', async () => { + await act(async () => { + render( + + + + + + ); + }); }); diff --git a/client/src/__test__/components/TaskTimeLeft.test.tsx b/client/src/__test__/components/TaskTimeLeft.test.tsx index 1143022..a94ebee 100644 --- a/client/src/__test__/components/TaskTimeLeft.test.tsx +++ b/client/src/__test__/components/TaskTimeLeft.test.tsx @@ -4,13 +4,23 @@ import { describe, expect, it } from 'vitest'; import TaskTimeLeft from '../../components/TaskTimeLeft'; describe('TaskTimeLeft Component', () => { + const renderComponent = (done: boolean) => { + return render( + + ); + }; + it('should render the TaskTimeLeft component with text when task is not done', () => { - const { getByText } = render(); + const { getByText } = renderComponent(false); expect(getByText('2 days left')).toBeDefined(); }); it('should not render the TaskTimeLeft component when task is done', () => { - const { queryByText } = render(); + const { queryByText } = renderComponent(true); expect(queryByText('2 days left')).toBeNull(); }); }); diff --git a/client/src/__test__/context/AuthProvider.test.tsx b/client/src/__test__/context/AuthProvider.test.tsx index 4375663..57af514 100644 --- a/client/src/__test__/context/AuthProvider.test.tsx +++ b/client/src/__test__/context/AuthProvider.test.tsx @@ -146,20 +146,26 @@ describe('AuthProvider', () => { vi.spyOn(api, 'putJSON').mockResolvedValue(fakeResponse); - const { getByTestId } = render( - - - - ); + const user = userEvent.setup(); + let getByTestIdFunction; await act(async () => { - userEvent.click(getByTestId('register')); + const { getByTestId } = render( + + + + ); + 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( - - - - ); + const user = userEvent.setup(); - await waitFor(() => { - userEvent.click(getByTestId('checkCurrentAuthUser')); + let getByTestIdFunction; + await act(async () => { + const { getByTestId } = render( + + + + ); + 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') ); diff --git a/client/src/__test__/views/Task.test.tsx b/client/src/__test__/views/Task.test.tsx index 7bd1621..8f5b21c 100644 --- a/client/src/__test__/views/Task.test.tsx +++ b/client/src/__test__/views/Task.test.tsx @@ -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( ); + }; + + 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(); + }); }); diff --git a/client/src/components/FormInput/custom-datepicker.css b/client/src/components/FormInput/custom-datepicker.css new file mode 100644 index 0000000..f4999b6 --- /dev/null +++ b/client/src/components/FormInput/custom-datepicker.css @@ -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; + } +} diff --git a/client/src/components/FormInput/index.tsx b/client/src/components/FormInput/index.tsx index 5cf81e1..8e1e87e 100644 --- a/client/src/components/FormInput/index.tsx +++ b/client/src/components/FormInput/index.tsx @@ -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) => void; + value?: string; + valueDate?: Date | null; + onChange?: (e: React.ChangeEvent) => void; + onChangeDate?: (date: Date | null) => void; data_testid?: string; } @@ -74,15 +81,49 @@ function FormInput(props: React.PropsWithChildren): React.ReactNode { - + {props.type == 'date' + ? ( + { + 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 { + return state; + } + } + ]} + // Mobile-friendly options + withPortal + showYearDropdown + showMonthDropdown + dropdownMode="select" + /> + ) + : ( + + )} diff --git a/client/src/components/TaskTimeLeft/index.tsx b/client/src/components/TaskTimeLeft/index.tsx index d9e745a..7bd65e4 100644 --- a/client/src/components/TaskTimeLeft/index.tsx +++ b/client/src/components/TaskTimeLeft/index.tsx @@ -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): 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 : (
- - {props.text} - + {props.tooltip.length > 0 && ( + + {tooltipDate} + + )} + > + + {props.text} + + + )} + {props.tooltip.length === 0 && ( + + {props.text} + + )}
); } diff --git a/client/src/components/TaskTitle/index.tsx b/client/src/components/TaskTitle/index.tsx index d8efe09..18db11d 100644 --- a/client/src/components/TaskTitle/index.tsx +++ b/client/src/components/TaskTitle/index.tsx @@ -20,26 +20,32 @@ interface Props { function TaskTitle(props: React.PropsWithChildren): React.ReactNode { if (props.highPriority) { return ( - + {props.done ? ( - + ) : ( - + )} - + {props.title} ); } return ( - + {props.done && ( - + )} - + {props.title} diff --git a/client/src/views/Task/index.tsx b/client/src/views/Task/index.tsx index 40c30cc..043bdfe 100644 --- a/client/src/views/Task/index.tsx +++ b/client/src/views/Task/index.tsx @@ -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([]); const [savedTasks, setSavedTasks] = useState([]); const [filterText, setFilterText] = useState(''); + const [selectedOption, setSelectedOption] = useState('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): 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) => filterTasks(e.target.value)} + onChange={(e: React.ChangeEvent) => filterTasks(e.target.value, selectedOption)} /> @@ -164,6 +180,48 @@ function Task(): React.ReactNode { + + +
+
+ + + +
+
+ +
+ {tasks.map((task: TaskResponse) => ( @@ -204,7 +262,11 @@ function Task(): React.ReactNode { {task.dueDateFmt && ( - + )} {task.urls.length > 0 && ( diff --git a/client/src/views/Task/style.css b/client/src/views/Task/style.css index d8c1a9e..0b674ac 100644 --- a/client/src/views/Task/style.css +++ b/client/src/views/Task/style.css @@ -67,4 +67,79 @@ .task-card-footer { background-color: #fff !important; border-top: #fff !important; -} \ No newline at end of file +} + +/* 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; + } +} diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx index 6b57b29..3c073de 100644 --- a/client/src/views/TaskAdd/index.tsx +++ b/client/src/views/TaskAdd/index.tsx @@ -33,7 +33,7 @@ function TaskAdd(): React.ReactNode { const [taskUrl, setTaskUrl] = useState(''); const [taskDone, setTaskDone] = useState(false); const [action, setAction] = useState('add'); - const [dueDate, setDueDate] = useState(''); + const [dueDate, setDueDate] = useState(null); const [highPriority, setHighPriority] = useState(false); const [tag, setTag] = useState(''); 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) => { - setDueDate(e.target.value); + valueDate={dueDate} + onChangeDate={(date: Date | null) => { + setDueDate(date); }} />