Merge remote-tracking branch 'origin/master' into 3293-x-com-tx-per-link-concept
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
**/node_modules
|
||||
**/build
|
||||
**/coverage
|
||||
20
.github/workflows/publish.yml
vendored
@ -38,7 +38,7 @@ jobs:
|
||||
docker build --target production -t "gradido/frontend:latest" -t "gradido/frontend:production" -t "gradido/frontend:${VERSION}" -t "gradido/frontend:${BUILD_VERSION}" frontend/
|
||||
docker save "gradido/frontend" > /tmp/frontend.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-frontend-production
|
||||
path: /tmp/frontend.tar
|
||||
@ -75,7 +75,7 @@ jobs:
|
||||
docker build -f ./backend/Dockerfile --target production -t "gradido/backend:latest" -t "gradido/backend:production" -t "gradido/backend:${VERSION}" -t "gradido/backend:${BUILD_VERSION}" .
|
||||
docker save "gradido/backend" > /tmp/backend.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-backend-production
|
||||
path: /tmp/backend.tar
|
||||
@ -101,7 +101,7 @@ jobs:
|
||||
docker build --target production_up -t "gradido/database:production_up" database/
|
||||
docker save "gradido/database:production_up" > /tmp/database_up.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-database-production_up
|
||||
path: /tmp/database_up.tar
|
||||
@ -138,7 +138,7 @@ jobs:
|
||||
docker build -t "gradido/mariadb:latest" -t "gradido/mariadb:production" -t "gradido/mariadb:${VERSION}" -t "gradido/mariadb:${BUILD_VERSION}" -f ./mariadb/Dockerfile ./
|
||||
docker save "gradido/mariadb" > /tmp/mariadb.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-mariadb-production
|
||||
path: /tmp/mariadb.tar
|
||||
@ -175,7 +175,7 @@ jobs:
|
||||
docker build -t "gradido/nginx:latest" -t "gradido/nginx:production" -t "gradido/nginx:${VERSION}" -t "gradido/nginx:${BUILD_VERSION}" nginx/
|
||||
docker save "gradido/nginx" > /tmp/nginx.tar
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-nginx-production
|
||||
path: /tmp/nginx.tar
|
||||
@ -200,35 +200,35 @@ jobs:
|
||||
# DOWNLOAD DOCKER IMAGES #################################################
|
||||
##########################################################################
|
||||
- name: Download Docker Image (Frontend)
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-frontend-production
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/frontend.tar
|
||||
- name: Download Docker Image (Backend)
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-backend-production
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/backend.tar
|
||||
- name: Download Docker Image (Database)
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-database-production_up
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/database_up.tar
|
||||
- name: Download Docker Image (MariaDB)
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-mariadb-production
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/mariadb.tar
|
||||
- name: Download Docker Image (Nginx)
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-nginx-production
|
||||
path: /tmp
|
||||
|
||||
2
.github/workflows/test_admin_interface.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Admin Interface | Build 'test' image
|
||||
run: docker build --target test -t "gradido/admin:test" admin/ --build-arg NODE_ENV="test"
|
||||
run: docker build -f ./admin/Dockerfile --target test -t "gradido/admin:test" --build-arg NODE_ENV="test" .
|
||||
|
||||
unit_test:
|
||||
if: needs.files-changed.outputs.admin == 'true'
|
||||
|
||||
8
.github/workflows/test_backend.yml
vendored
@ -44,17 +44,17 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Backend | docker-compose mariadb
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
|
||||
|
||||
- name: Sleep for 30 seconds
|
||||
run: sleep 30s
|
||||
shell: bash
|
||||
|
||||
- name: Backend | docker-compose database
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
|
||||
|
||||
- name: Backend | Unit tests
|
||||
run: cd database && yarn && yarn build && cd ../backend && yarn && yarn test
|
||||
run: cd database && yarn && yarn build && cd ../config && yarn install && cd ../backend && yarn && yarn test
|
||||
|
||||
lint:
|
||||
if: needs.files-changed.outputs.backend == 'true'
|
||||
@ -66,7 +66,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Backend | Lint
|
||||
run: cd database && yarn && cd ../backend && yarn && yarn run lint
|
||||
run: cd database && yarn && cd ../config && yarn install && cd ../backend && yarn && yarn run lint
|
||||
|
||||
locales:
|
||||
if: needs.files-changed.outputs.backend == 'true'
|
||||
|
||||
6
.github/workflows/test_database.yml
vendored
@ -43,13 +43,13 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Database | docker-compose
|
||||
run: docker-compose -f docker-compose.yml up --detach mariadb
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb
|
||||
|
||||
- name: Database | up
|
||||
run: docker-compose -f docker-compose.yml run -T database yarn up
|
||||
run: docker compose -f docker-compose.yml up --no-deps database
|
||||
|
||||
- name: Database | reset
|
||||
run: docker-compose -f docker-compose.yml run -T database yarn reset
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.reset.yml up --no-deps database
|
||||
|
||||
lint:
|
||||
if: needs.files-changed.outputs.database == 'true'
|
||||
|
||||
26
.github/workflows/test_dht_node.yml
vendored
@ -29,14 +29,14 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- name: Build 'test' image
|
||||
run: |
|
||||
docker build --target test -t "gradido/dht-node:test" -f dht-node/Dockerfile .
|
||||
docker save "gradido/dht-node:test" > /tmp/dht-node.tar
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-dht-node-test
|
||||
path: /tmp/dht-node.tar
|
||||
@ -49,9 +49,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- name: Lint
|
||||
run: cd database && yarn && cd ../dht-node && yarn && yarn run lint
|
||||
run: cd database && yarn && cd ../config && yarn install && cd ../dht-node && yarn && yarn run lint
|
||||
|
||||
unit_test:
|
||||
name: Unit Tests - DHT Node
|
||||
@ -61,31 +61,31 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- name: Download Docker Image
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-dht-node-test
|
||||
path: /tmp
|
||||
|
||||
- name: Load Docker Image
|
||||
run: docker load < /tmp/dht-node.tar
|
||||
|
||||
|
||||
- name: docker-compose mariadb
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
|
||||
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
|
||||
|
||||
- name: Sleep for 30 seconds
|
||||
run: sleep 30s
|
||||
shell: bash
|
||||
|
||||
- name: docker-compose database
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
|
||||
|
||||
- name: Sleep for 30 seconds
|
||||
run: sleep 30s
|
||||
shell: bash
|
||||
|
||||
#- name: Unit tests
|
||||
# run: cd database && yarn && yarn build && cd ../dht-node && yarn && yarn test
|
||||
- name: Unit tests
|
||||
run: docker run --env NODE_ENV=test --env DB_HOST=mariadb --network gradido_internal-net --rm gradido/dht-node:test yarn run test
|
||||
run: cd database && yarn && yarn build && cd ../config && yarn install && cd ../dht-node && yarn && yarn test
|
||||
#- name: Unit tests
|
||||
# run: docker run --env NODE_ENV=test --env DB_HOST=mariadb --network gradido_internal-net --rm gradido/dht-node:test yarn run test
|
||||
|
||||
4
.github/workflows/test_dlt_connector.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
docker save "gradido/dlt-connector:test" > /tmp/dlt-connector.tar
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-dlt-connector-test
|
||||
path: /tmp/dlt-connector.tar
|
||||
@ -62,7 +62,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: DLT-Connector | docker-compose mariadb
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
|
||||
|
||||
- name: Sleep for 30 seconds
|
||||
run: sleep 30s
|
||||
|
||||
29
.github/workflows/test_e2e.yml
vendored
@ -5,23 +5,13 @@ on: push
|
||||
jobs:
|
||||
end-to-end-tests:
|
||||
name: End-to-End Tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Boot up test system | docker-compose mariadb
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb
|
||||
|
||||
- name: Boot up test system | docker-compose database
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
|
||||
|
||||
- name: Boot up test system | docker-compose backend
|
||||
run: |
|
||||
cd backend
|
||||
cp .env.test_e2e .env
|
||||
cd ..
|
||||
docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb
|
||||
|
||||
- name: Sleep for 10 seconds
|
||||
run: sleep 10s
|
||||
@ -31,14 +21,17 @@ jobs:
|
||||
sudo chown runner:docker -R *
|
||||
cd database
|
||||
yarn && yarn dev_reset
|
||||
cd ../config
|
||||
yarn install
|
||||
cd ../backend
|
||||
yarn && yarn seed
|
||||
|
||||
- name: Boot up test system | docker-compose frontends
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps frontend admin nginx
|
||||
|
||||
- name: Boot up test system | docker-compose mailserver
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mailserver
|
||||
- name: Boot up test system | docker-compose backend, frontend, admin, nginx, mailserver
|
||||
run: |
|
||||
cd backend
|
||||
cp .env.test_e2e .env
|
||||
cd ..
|
||||
docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend frontend admin nginx mailserver
|
||||
|
||||
- name: End-to-end tests | prepare
|
||||
run: |
|
||||
@ -68,7 +61,7 @@ jobs:
|
||||
- name: End-to-end tests | if tests failed, upload report
|
||||
id: e2e-report
|
||||
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-report-pr-#${{ steps.pr.outputs.number }}
|
||||
path: /home/runner/work/gradido/gradido/e2e-tests/cypress/reports/cucumber_html_report
|
||||
|
||||
8
.github/workflows/test_federation.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
docker save "gradido/federation:test" > /tmp/federation.tar
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-federation-test
|
||||
path: /tmp/federation.tar
|
||||
@ -62,7 +62,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download Docker Image
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-federation-test
|
||||
path: /tmp
|
||||
@ -71,14 +71,14 @@ jobs:
|
||||
run: docker load < /tmp/federation.tar
|
||||
|
||||
- name: docker-compose mariadb
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
|
||||
|
||||
- name: Sleep for 30 seconds
|
||||
run: sleep 30s
|
||||
shell: bash
|
||||
|
||||
- name: docker-compose database
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
|
||||
|
||||
- name: Sleep for 30 seconds
|
||||
run: sleep 30s
|
||||
|
||||
2
.github/workflows/test_frontend.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Frontend | Build 'test' image
|
||||
run: docker build --target test -t "gradido/frontend:test" frontend/ --build-arg NODE_ENV="test"
|
||||
run: docker build -f ./frontend/Dockerfile --target test -t "gradido/frontend:test" --build-arg NODE_ENV="test" .
|
||||
|
||||
unit_test:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
|
||||
32
.github/workflows/test_mariadb.yml
vendored
@ -1,32 +0,0 @@
|
||||
name: Gradido MariaDB Test CI
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Detect File Changes - MariaDB
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
mariadb: ${{ steps.changes.outputs.mariadb }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3.3.0
|
||||
|
||||
- name: Check for frontend file changes
|
||||
uses: dorny/paths-filter@v2.11.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-filters.yml
|
||||
list-files: shell
|
||||
|
||||
build_test:
|
||||
if: needs.files-changed.outputs.mariadb == 'true'
|
||||
name: Docker Build Test - MariaDB
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: MariaDB | Build 'test' image
|
||||
run: docker build --target mariadb_server -t "gradido/mariadb:test" -f ./mariadb/Dockerfile ./
|
||||
141
CHANGELOG.md
@ -4,8 +4,149 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [2.5.1](https://github.com/gradido/gradido/compare/2.3.1...2.5.1)
|
||||
|
||||
- refactor(frontend): contribution form refactor [`#3442`](https://github.com/gradido/gradido/pull/3442)
|
||||
- feat(backend): correct user data transfer to gms [`#3433`](https://github.com/gradido/gradido/pull/3433)
|
||||
- feat(backend): switch marker colors of community location and user location [`#3445`](https://github.com/gradido/gradido/pull/3445)
|
||||
- feat(backend): darker email font for content [`#3441`](https://github.com/gradido/gradido/pull/3441)
|
||||
- fix(other): clear also ~/.cache/yarn [`#3362`](https://github.com/gradido/gradido/pull/3362)
|
||||
- refactor(backend): test refactor better bun compatibility [`#3438`](https://github.com/gradido/gradido/pull/3438)
|
||||
- chore(release): v2.4.5 beta [`#3435`](https://github.com/gradido/gradido/pull/3435)
|
||||
- feat(backend): add answer button inside E-Mail [`#3431`](https://github.com/gradido/gradido/pull/3431)
|
||||
- feat(other): build config in deployment [`#3430`](https://github.com/gradido/gradido/pull/3430)
|
||||
- fix(dht): and federation using config in bare-metal setup [`#3434`](https://github.com/gradido/gradido/pull/3434)
|
||||
- fix(frontend): fix meta url from env.template [`#3432`](https://github.com/gradido/gradido/pull/3432)
|
||||
- fix(frontend): fix problems with dev container [`#3429`](https://github.com/gradido/gradido/pull/3429)
|
||||
- feat(backend): add config validation with joi [`#3422`](https://github.com/gradido/gradido/pull/3422)
|
||||
- feat(backend): patch getUsername in PublishNameLogic [`#3428`](https://github.com/gradido/gradido/pull/3428)
|
||||
- feat(admin): resubmission date localized [`#3426`](https://github.com/gradido/gradido/pull/3426)
|
||||
- refactor(workflow): increaste artifact version [`#3425`](https://github.com/gradido/gradido/pull/3425)
|
||||
- feat(backend): make sure, correct user name is used for jwt token [`#3424`](https://github.com/gradido/gradido/pull/3424)
|
||||
- feat(frontend): speedup frontend build time [`#3423`](https://github.com/gradido/gradido/pull/3423)
|
||||
- chore(release): v2.4.4 beta [`#3421`](https://github.com/gradido/gradido/pull/3421)
|
||||
- fix(admin): copy of env variables [`#3420`](https://github.com/gradido/gradido/pull/3420)
|
||||
- fix(backend): toggle for using worker threads for password encryption [`#3418`](https://github.com/gradido/gradido/pull/3418)
|
||||
- fix(admin): fix resubmission [`#3416`](https://github.com/gradido/gradido/pull/3416)
|
||||
- feat(backend): run password encryption in worker thread [`#3410`](https://github.com/gradido/gradido/pull/3410)
|
||||
- feat(frontend): user location with vue3 in cooperation with montreal [`#3397`](https://github.com/gradido/gradido/pull/3397)
|
||||
- feat(backend): optimize find user query on login [`#3415`](https://github.com/gradido/gradido/pull/3415)
|
||||
- feat(frontend): show gdt comments [`#3412`](https://github.com/gradido/gradido/pull/3412)
|
||||
- fix(backend): humhub use first name instead of username field for initalien based alias [`#3403`](https://github.com/gradido/gradido/pull/3403)
|
||||
- fix(frontend): repair gdt transaction list pagination [`#3409`](https://github.com/gradido/gradido/pull/3409)
|
||||
- fix(frontend): updated tests [`#3406`](https://github.com/gradido/gradido/pull/3406)
|
||||
- feat(backend): speed up gdt request with keep alive connections [`#3392`](https://github.com/gradido/gradido/pull/3392)
|
||||
- fix(frontend): fix contribution data handling [`#3405`](https://github.com/gradido/gradido/pull/3405)
|
||||
- feat(database): use the same mariadb version everywhere [`#3396`](https://github.com/gradido/gradido/pull/3396)
|
||||
- feat(frontend): scale down two img [`#3393`](https://github.com/gradido/gradido/pull/3393)
|
||||
- feat(other): remove docker autostart [`#3395`](https://github.com/gradido/gradido/pull/3395)
|
||||
- feat(frontend): add feedback fixes + map feature fixes [`#3400`](https://github.com/gradido/gradido/pull/3400)
|
||||
- feat(other): increase e2e page load timeout [`#3394`](https://github.com/gradido/gradido/pull/3394)
|
||||
- chore(release): v2.4.1 beta [`#3388`](https://github.com/gradido/gradido/pull/3388)
|
||||
- chore(release): v2.4.0 beta [`#3387`](https://github.com/gradido/gradido/pull/3387)
|
||||
- feat(backend): auto register new user in humhub [`#3386`](https://github.com/gradido/gradido/pull/3386)
|
||||
- feat(backend): try and catch user sync [`#3385`](https://github.com/gradido/gradido/pull/3385)
|
||||
- feat(backend): increase initialien count [`#3369`](https://github.com/gradido/gradido/pull/3369)
|
||||
- feat(frontend): monterail vue3 migration [`#3383`](https://github.com/gradido/gradido/pull/3383)
|
||||
- fix(frontend): fix postmigration fix [`#3382`](https://github.com/gradido/gradido/pull/3382)
|
||||
- feat(frontend): update text [`#3373`](https://github.com/gradido/gradido/pull/3373)
|
||||
- feat(frontend): fix postmigration fix [`#3378`](https://github.com/gradido/gradido/pull/3378)
|
||||
- feat(frontend): map feature in vue 3 [`#3376`](https://github.com/gradido/gradido/pull/3376)
|
||||
- feat(frontend): links and emails in messages [`#3377`](https://github.com/gradido/gradido/pull/3377)
|
||||
- feat(frontend): add transaction link in latest transactions [`#3375`](https://github.com/gradido/gradido/pull/3375)
|
||||
- fix(frontend): fix logout issue [`#3374`](https://github.com/gradido/gradido/pull/3374)
|
||||
- fix(frontend): post migration fixes [`#3372`](https://github.com/gradido/gradido/pull/3372)
|
||||
- feat(frontend): vue3 migration [`#3365`](https://github.com/gradido/gradido/pull/3365)
|
||||
- fix(frontend): fix index.html [`#3368`](https://github.com/gradido/gradido/pull/3368)
|
||||
- build(frontend): merged code from master [`#3367`](https://github.com/gradido/gradido/pull/3367)
|
||||
- fix(frontend): vue3 migration pre deploy setup [`#3366`](https://github.com/gradido/gradido/pull/3366)
|
||||
- fix(workflow): fix broken tests [`#3363`](https://github.com/gradido/gradido/pull/3363)
|
||||
- fix(frontend): style fixes, admin fix [`#3364`](https://github.com/gradido/gradido/pull/3364)
|
||||
- fix(frontend): gdt test [`#3361`](https://github.com/gradido/gradido/pull/3361)
|
||||
- fix(frontend): style fixes [`#3360`](https://github.com/gradido/gradido/pull/3360)
|
||||
- fix(frontend): migration feedback fixes [`#3359`](https://github.com/gradido/gradido/pull/3359)
|
||||
- fix(frontend): scss changes and fixes [`#3358`](https://github.com/gradido/gradido/pull/3358)
|
||||
- fix(frontend): migration remaining fixes [`#3356`](https://github.com/gradido/gradido/pull/3356)
|
||||
- fix(admin): fix message update [`#3354`](https://github.com/gradido/gradido/pull/3354)
|
||||
- fix(admin): fix refetch data in edit creation form [`#3353`](https://github.com/gradido/gradido/pull/3353)
|
||||
- fix(frontend): fix dropdown in transaction send and link [`#3352`](https://github.com/gradido/gradido/pull/3352)
|
||||
- fix(frontend): fix newsletter state reactivity [`#3351`](https://github.com/gradido/gradido/pull/3351)
|
||||
- fix(frontend): fix how community switch is handled [`#3350`](https://github.com/gradido/gradido/pull/3350)
|
||||
- fix(frontend): fixed after merge [`#3349`](https://github.com/gradido/gradido/pull/3349)
|
||||
- fix(frontend): fixed logout handler [`#3347`](https://github.com/gradido/gradido/pull/3347)
|
||||
- chore(frontend): main js cleanup [`#3346`](https://github.com/gradido/gradido/pull/3346)
|
||||
- feature(frontend): change env config reading [`#3345`](https://github.com/gradido/gradido/pull/3345)
|
||||
- feature(frontend): bump node in FE .nvmrc [`#3344`](https://github.com/gradido/gradido/pull/3344)
|
||||
- feat(frontend): migration setup [`#3342`](https://github.com/gradido/gradido/pull/3342)
|
||||
- fix(admin): Remove "maxAmountPerMonth" from `createContributionLink` gql. [`#3343`](https://github.com/gradido/gradido/pull/3343)
|
||||
- fix(admin): style fixes [`#3339`](https://github.com/gradido/gradido/pull/3339)
|
||||
- fix(admin): creation tab disappearing after creating creation [`#3338`](https://github.com/gradido/gradido/pull/3338)
|
||||
- fix(frontend): show updated gdd amount [`#3337`](https://github.com/gradido/gradido/pull/3337)
|
||||
- feat(admin): Add remaining fixes [`#3336`](https://github.com/gradido/gradido/pull/3336)
|
||||
- feat(admin): fix edit creation form [`#3334`](https://github.com/gradido/gradido/pull/3334)
|
||||
- feat(admin): migration of admin creation components [`#3333`](https://github.com/gradido/gradido/pull/3333)
|
||||
- feat(admin): automatic contributions updates [`#3332`](https://github.com/gradido/gradido/pull/3332)
|
||||
- feat(admin): vite config changes [`#3331`](https://github.com/gradido/gradido/pull/3331)
|
||||
- feat(admin) - fix import in node server [`#3330`](https://github.com/gradido/gradido/pull/3330)
|
||||
- fix(admin): stylelint fix [`#3329`](https://github.com/gradido/gradido/pull/3329)
|
||||
- feat(admin): setup migration environment [`#3328`](https://github.com/gradido/gradido/pull/3328)
|
||||
- fix(admin): fix contribution link [`#3326`](https://github.com/gradido/gradido/pull/3326)
|
||||
- feat(admin): geo-coordinates for community [`#3323`](https://github.com/gradido/gradido/pull/3323)
|
||||
- feat(backend): speedup listTransactions [`#3324`](https://github.com/gradido/gradido/pull/3324)
|
||||
- fix(frontend): link forwarding after using send with url parameters [`#3322`](https://github.com/gradido/gradido/pull/3322)
|
||||
|
||||
#### [2.3.1](https://github.com/gradido/gradido/compare/2.3.0...2.3.1)
|
||||
|
||||
> 11 June 2024
|
||||
|
||||
- chore(release): v2.3.1 beta [`#3321`](https://github.com/gradido/gradido/pull/3321)
|
||||
- feat(frontend): more compatible humhub auto-login link [`#3319`](https://github.com/gradido/gradido/pull/3319)
|
||||
- fix(backend): fix test which will only fail at 31. of month, or 30.05 [`#3320`](https://github.com/gradido/gradido/pull/3320)
|
||||
- feat(frontend): remove automatically logged out message [`#3318`](https://github.com/gradido/gradido/pull/3318)
|
||||
|
||||
#### [2.3.0](https://github.com/gradido/gradido/compare/2.2.1...2.3.0)
|
||||
|
||||
> 20 May 2024
|
||||
|
||||
- chore(release): v2.3.0 beta [`#3316`](https://github.com/gradido/gradido/pull/3316)
|
||||
- feat(frontend): conditional services menu entries [`#3314`](https://github.com/gradido/gradido/pull/3314)
|
||||
- feat(frontend): update news [`#3313`](https://github.com/gradido/gradido/pull/3313)
|
||||
- feat(frontend): auto-login link for humhub [`#3311`](https://github.com/gradido/gradido/pull/3311)
|
||||
- feat(frontend): remove gdt menu entry [`#3312`](https://github.com/gradido/gradido/pull/3312)
|
||||
- feat(backend): sync user change with humhub [`#3310`](https://github.com/gradido/gradido/pull/3310)
|
||||
- feat(backend): humhub export user function [`#3308`](https://github.com/gradido/gradido/pull/3308)
|
||||
- feat(frontend): add humhub switch and name format [`#3309`](https://github.com/gradido/gradido/pull/3309)
|
||||
- feat(frontend): gms - new menu item userplayground [`#3307`](https://github.com/gradido/gradido/pull/3307)
|
||||
- feat(backend): gms user-search - backend authentication-handshake [`#3306`](https://github.com/gradido/gradido/pull/3306)
|
||||
|
||||
#### [2.2.1](https://github.com/gradido/gradido/compare/2.2.0...2.2.1)
|
||||
|
||||
> 2 April 2024
|
||||
|
||||
- fix(other): v2.2.1 merge with master [`#3296`](https://github.com/gradido/gradido/pull/3296)
|
||||
- feat(backend): 3287 feature gms user update after change user settings [`#3291`](https://github.com/gradido/gradido/pull/3291)
|
||||
- docs(backend): 3302 docu usecase - user search [`#3303`](https://github.com/gradido/gradido/pull/3303)
|
||||
- fix(backend): prevent updating alias backend-side [`#3300`](https://github.com/gradido/gradido/pull/3300)
|
||||
- fix(other): add GMS_ACTIVE and GMS_URL to deploy .env.dist [`#3301`](https://github.com/gradido/gradido/pull/3301)
|
||||
- feat(frontend): update gms user settings [`#3297`](https://github.com/gradido/gradido/pull/3297)
|
||||
- feat(admin): admin update community list [`#3271`](https://github.com/gradido/gradido/pull/3271)
|
||||
- refactor(backend): refactor community and add to dlt-connector [`#3289`](https://github.com/gradido/gradido/pull/3289)
|
||||
- feat(frontend): serve frontend and admin static [`#3278`](https://github.com/gradido/gradido/pull/3278)
|
||||
- build(other): add config for nvm [`#3292`](https://github.com/gradido/gradido/pull/3292)
|
||||
- feat(dlt): transmit to iota [`#3275`](https://github.com/gradido/gradido/pull/3275)
|
||||
- fix(other): deployment bugfixes [`#3290`](https://github.com/gradido/gradido/pull/3290)
|
||||
- fix(backend): prevent running validate communities continuous [`#3288`](https://github.com/gradido/gradido/pull/3288)
|
||||
- feat(workflow): add videos to deployment readme [`#3286`](https://github.com/gradido/gradido/pull/3286)
|
||||
- feat(backend): 3263 feature gms publish user backend update user settings [`#3281`](https://github.com/gradido/gradido/pull/3281)
|
||||
- feat(backend): gms publish user backend gradido communities dialog [`#3265`](https://github.com/gradido/gradido/pull/3265)
|
||||
- feat(backend): 3237 feature connect gms api with a new rest client [`#3256`](https://github.com/gradido/gradido/pull/3256)
|
||||
|
||||
#### [2.2.0](https://github.com/gradido/gradido/compare/2.1.1...2.2.0)
|
||||
|
||||
> 9 February 2024
|
||||
|
||||
- chore(release): v2.2.0 [`#3283`](https://github.com/gradido/gradido/pull/3283)
|
||||
- fix(backend): prevent warning, fix env error [`#3282`](https://github.com/gradido/gradido/pull/3282)
|
||||
- feat(frontend): update news text [`#3279`](https://github.com/gradido/gradido/pull/3279)
|
||||
- feat(frontend): use params instead of query for send/identifier route [`#3277`](https://github.com/gradido/gradido/pull/3277)
|
||||
- fix(other): deployment bugfixes [`#3276`](https://github.com/gradido/gradido/pull/3276)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
GRAPHQL_URL=http://localhost:4000
|
||||
GRAPHQL_PATH=/graphql
|
||||
WALLET_URL=http://localhost
|
||||
WALLET_AUTH_PATH=/authenticate?token={token}
|
||||
WALLET_AUTH_PATH=/authenticate?token=
|
||||
WALLET_LOGIN_PATH=/login
|
||||
DEBUG_DISABLE_AUTH=false
|
||||
DEBUG_DISABLE_AUTH=false
|
||||
HUMHUB_ACTIVE=false
|
||||
OPENAI_ACTIVE=false
|
||||
@ -6,3 +6,7 @@ WALLET_AUTH_PATH=$WALLET_AUTH_PATH
|
||||
WALLET_LOGIN_PATH=$WALLET_LOGIN_PATH
|
||||
GRAPHQL_PATH=$GRAPHQL_PATH
|
||||
DEBUG_DISABLE_AUTH=false
|
||||
|
||||
HUMHUB_ACTIVE=$HUMHUB_ACTIVE
|
||||
HUMHUB_API_URL=$HUMHUB_API_URL
|
||||
OPENAI_ACTIVE=$OPENAI_ACTIVE
|
||||
@ -3,19 +3,20 @@ module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
jest: true,
|
||||
'vue/setup-compiler-macros': true,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
extends: [
|
||||
'standard',
|
||||
'plugin:vue/essential',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:@intlify/vue-i18n/recommended',
|
||||
'prettier',
|
||||
],
|
||||
// required to lint *.vue files
|
||||
plugins: ['vue', 'prettier', 'jest'],
|
||||
plugins: ['vue', 'prettier'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.json'],
|
||||
@ -26,23 +27,31 @@ module.exports = {
|
||||
rules: {
|
||||
'no-console': ['error'],
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'node/no-callback-literal': 0, // This is here to allow tests run properly
|
||||
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
|
||||
'vue/no-static-inline-styles': [
|
||||
'error',
|
||||
{
|
||||
allowBinding: false,
|
||||
},
|
||||
],
|
||||
// 'vue/no-static-inline-styles': [
|
||||
// 'error',
|
||||
// {
|
||||
// allowBinding: false,
|
||||
// },
|
||||
// ],
|
||||
'vue/multi-word-component-names': 0,
|
||||
'vue/no-v-html': 0,
|
||||
'vue/no-static-inline-styles': 0, // TODO remove at the end of migration and fix
|
||||
'vue/require-default-prop': 0, // TODO remove at the end of migration and fix
|
||||
'vue/no-computed-properties-in-data': 0, // TODO remove at the end of migration and fix
|
||||
'@intlify/vue-i18n/no-dynamic-keys': 'error',
|
||||
'@intlify/vue-i18n/no-unused-keys': [
|
||||
'error',
|
||||
{
|
||||
src: './src',
|
||||
extensions: ['.js', '.vue'],
|
||||
ignores: ['/overlay/'],
|
||||
enableFix: false,
|
||||
},
|
||||
],
|
||||
'@intlify/vue-i18n/no-raw-text': 0, // TODO remove at the end of migration and fix
|
||||
// '@intlify/vue-i18n/no-unused-keys': [
|
||||
// 'error',
|
||||
// {
|
||||
// src: './src',
|
||||
// extensions: ['.js', '.vue'],
|
||||
// ignores: ['/overlay/'],
|
||||
// enableFix: false,
|
||||
// },
|
||||
// ],
|
||||
'@intlify/vue-i18n/no-unused-keys': 0, // TODO remove at the end of migration and fix
|
||||
'@intlify/vue-i18n/no-missing-keys-in-other-locales': 'error',
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
|
||||
3
admin/.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
build/
|
||||
.cache/
|
||||
.yarn/install-state.gz
|
||||
|
||||
/.env
|
||||
/.env.bak
|
||||
@ -10,3 +11,5 @@ coverage/
|
||||
|
||||
# emacs
|
||||
*~
|
||||
|
||||
components.d.ts
|
||||
|
||||
@ -1 +1 @@
|
||||
v14.17.0
|
||||
v18.20
|
||||
@ -1,18 +1,17 @@
|
||||
'use strict';
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
extends: ["stylelint-config-standard-scss", "stylelint-config-recommended-vue"],
|
||||
extends: ['stylelint-config-standard-scss', 'stylelint-config-recommended-vue'],
|
||||
overrides: [
|
||||
{
|
||||
files: "**/*.{scss}",
|
||||
customSyntax: "postcss-scss",
|
||||
extends: ["stylelint-config-standard-scss"],
|
||||
files: '**/*.{scss}',
|
||||
customSyntax: 'postcss-scss',
|
||||
extends: ['stylelint-config-standard-scss'],
|
||||
},
|
||||
{
|
||||
files: "**/*.vue",
|
||||
customSyntax: "postcss-html",
|
||||
extends: ["stylelint-config-recommended-vue"],
|
||||
}
|
||||
]
|
||||
|
||||
};
|
||||
files: '**/*.vue',
|
||||
customSyntax: 'postcss-html',
|
||||
extends: ['stylelint-config-recommended-vue'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
##################################################################################
|
||||
# BASE ###########################################################################
|
||||
##################################################################################
|
||||
FROM node:14.17.0-alpine3.10 as base
|
||||
FROM node:18.20-alpine3.20 as base
|
||||
|
||||
# ENVs (available in production aswell, can be overwritten by commandline or env file)
|
||||
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame
|
||||
@ -11,7 +11,7 @@ ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
|
||||
## We cannot do $(npm run version).${BUILD_NUMBER} here so we default to 0.0.0.0
|
||||
ENV BUILD_VERSION="0.0.0.0"
|
||||
## We cannot do `$(git rev-parse --short HEAD)` here so we default to 0000000
|
||||
ENV BUILD_COMMIT="0000000"
|
||||
ENV BUILD_COMMIT_SHORT="0000000"
|
||||
## SET NODE_ENV
|
||||
ARG NODE_ENV="production"
|
||||
## App relevant Envs
|
||||
@ -42,6 +42,8 @@ EXPOSE ${PORT}
|
||||
RUN mkdir -p ${DOCKER_WORKDIR}
|
||||
WORKDIR ${DOCKER_WORKDIR}
|
||||
|
||||
RUN mkdir -p /config
|
||||
|
||||
##################################################################################
|
||||
# DEVELOPMENT (Connected to the local environment, to reload on demand) ##########
|
||||
##################################################################################
|
||||
@ -53,7 +55,7 @@ FROM base as development
|
||||
# Run command
|
||||
# (for development we need to execute yarn install since the
|
||||
# node_modules are on another volume and need updating)
|
||||
CMD /bin/sh -c "yarn install && yarn run dev"
|
||||
CMD /bin/sh -c "cd /config && yarn install && cd /app && yarn && yarn run dev"
|
||||
|
||||
##################################################################################
|
||||
# BUILD (Does contain all files and is therefore bloated) ########################
|
||||
@ -61,9 +63,16 @@ CMD /bin/sh -c "yarn install && yarn run dev"
|
||||
FROM base as build
|
||||
|
||||
# Copy everything
|
||||
COPY . .
|
||||
# yarn install
|
||||
COPY ./admin/ .
|
||||
# Copy everything from config
|
||||
COPY ./config/ ../config/
|
||||
|
||||
# yarn install and build config
|
||||
RUN cd ../config && yarn install --production=false --frozen-lockfile --non-interactive && yarn build
|
||||
|
||||
# yarn install admin
|
||||
RUN yarn install --production=false --frozen-lockfile --non-interactive
|
||||
|
||||
# yarn build
|
||||
RUN yarn run build
|
||||
|
||||
@ -85,6 +94,7 @@ FROM base as production
|
||||
|
||||
# Copy "binary"-files from build image
|
||||
COPY --from=build ${DOCKER_WORKDIR}/build ./build
|
||||
COPY --from=build ${DOCKER_WORKDIR}/../config/build ../config/build
|
||||
# We also copy the node_modules express and serve-static for the run script
|
||||
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
|
||||
# Copy static files
|
||||
|
||||
@ -4,7 +4,7 @@ module.exports = function (api) {
|
||||
const presets = ['@babel/preset-env']
|
||||
const plugins = []
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
if (import.meta.env.NODE_ENV === 'test') {
|
||||
plugins.push('transform-require-context')
|
||||
}
|
||||
|
||||
|
||||
21
admin/index.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon.png">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
|
||||
<title>Gradido Admin Interface</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700">
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css" integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper" id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<!-- built files will be auto injected -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -1,36 +0,0 @@
|
||||
module.exports = {
|
||||
verbose: true,
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,vue}',
|
||||
'!**/node_modules/**',
|
||||
'!src/assets/**',
|
||||
'!**/?(*.)+(spec|test).js?(x)',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
lines: 96,
|
||||
},
|
||||
},
|
||||
moduleFileExtensions: [
|
||||
'js',
|
||||
// 'jsx',
|
||||
'json',
|
||||
'vue',
|
||||
],
|
||||
// coverageReporters: ['lcov', 'text'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'\\.(css|less)$': 'identity-obj-proxy',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.vue$': 'vue-jest',
|
||||
'^.+\\.(js|jsx)?$': 'babel-jest',
|
||||
'<rootDir>/node_modules/vee-validate/dist/rules': 'babel-jest',
|
||||
},
|
||||
setupFiles: ['<rootDir>/test/testSetup.js', 'jest-canvas-mock'],
|
||||
testMatch: ['**/?(*.)+(spec|test).js?(x)'],
|
||||
// snapshotSerializers: ['jest-serializer-vue'],
|
||||
transformIgnorePatterns: ['<rootDir>/node_modules/(?!vee-validate/dist/rules)'],
|
||||
testEnvironment: 'jest-environment-jsdom-sixteen', // why this is still needed? should not be needed anymore since jest@26, see: https://www.npmjs.com/package/jest-environment-jsdom-sixteen
|
||||
}
|
||||
@ -1,87 +1,97 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Administraion Interface for Gradido",
|
||||
"description": "Administration Interface for Gradido",
|
||||
"main": "index.js",
|
||||
"author": "Moriz Wahl",
|
||||
"version": "2.2.0",
|
||||
"version": "2.5.1",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"start": "node run/server.js",
|
||||
"serve": "vue-cli-service serve --open",
|
||||
"build": "vue-cli-service build",
|
||||
"dev": "yarn run serve",
|
||||
"analyse-bundle": "yarn build && webpack-bundle-analyzer build/webpack.stats.json",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"postbuild": "find build -type f -regex '.*\\.\\(html\\|js\\|css\\|svg\\|json\\)' -exec gzip -9 -k {} +",
|
||||
"lint": "eslint --max-warnings=0 --ext .js,.vue,.json .",
|
||||
"stylelint": "stylelint --max-warnings=0 '**/*.{scss,vue}'",
|
||||
"test": "cross-env TZ=UTC jest",
|
||||
"test": "cross-env TZ=UTC vitest run",
|
||||
"test:coverage": "cross-env TZ=UTC vitest run --coverage",
|
||||
"test:debug": "cross-env TZ=UTC node --inspect-brk ./node_modules/vitest/vitest.mjs",
|
||||
"test:watch": "cross-env TZ=UTC vitest",
|
||||
"locales": "scripts/sort.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.15.8",
|
||||
"@babel/eslint-parser": "^7.24.8",
|
||||
"@babel/node": "^7.15.8",
|
||||
"@babel/preset-env": "^7.15.8",
|
||||
"@vue/cli-plugin-unit-jest": "^4.5.14",
|
||||
"@iconify/json": "^2.2.228",
|
||||
"@vitejs/plugin-vue": "3.2.0",
|
||||
"@vue/apollo-composable": "^4.0.2",
|
||||
"@vue/apollo-option": "^4.0.0",
|
||||
"@vue/compat": "3.4.31",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/test-utils": "^1.2.2",
|
||||
"apollo-boost": "^0.4.9",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-jest": "^27.3.1",
|
||||
"babel-plugin-component": "^1.1.1",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"babel-preset-vue": "^2.0.2",
|
||||
"bootstrap": "4.3.1",
|
||||
"bootstrap-vue": "^2.21.2",
|
||||
"core-js": "^3.6.5",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-vue-next": "0.26.8",
|
||||
"date-fns": "^2.29.3",
|
||||
"dotenv-webpack": "^7.0.3",
|
||||
"express": "^4.17.1",
|
||||
"graphql": "^15.6.1",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "26.6.3",
|
||||
"jest-canvas-mock": "^2.3.1",
|
||||
"jest-environment-jsdom-sixteen": "^2.0.0",
|
||||
"portal-vue": "^2.1.7",
|
||||
"qrcanvas-vue": "2.1.1",
|
||||
"portal-vue": "3.0.0",
|
||||
"qrcanvas-vue": "3.0.0",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"stats-webpack-plugin": "^0.7.0",
|
||||
"vue": "^2.6.11",
|
||||
"vue-apollo": "^3.0.8",
|
||||
"vue-i18n": "^8.26.5",
|
||||
"vue-jest": "^3.0.7",
|
||||
"vue-router": "^3.5.3",
|
||||
"vuex": "^3.6.2",
|
||||
"vuex-persistedstate": "^4.1.0"
|
||||
"sass": "^1.77.8",
|
||||
"vite": "3.2.10",
|
||||
"vite-plugin-commonjs": "^0.10.1",
|
||||
"vue": "3.5.13",
|
||||
"vue-apollo": "3.1.2",
|
||||
"vue-i18n": "9.13.1",
|
||||
"vue-router": "4.4.0",
|
||||
"vue3-datepicker": "^0.4.0",
|
||||
"vuex": "4.1.0",
|
||||
"vuex-persistedstate": "4.1.0",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apollo/client": "^3.7.1",
|
||||
"@babel/eslint-parser": "^7.15.8",
|
||||
"@apollo/client": "^3.10.8",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^1.4.0",
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"@vue/compiler-sfc": "^3.4.32",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"babel-plugin-transform-require-context": "^0.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "7.25.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "8.10.0",
|
||||
"eslint-config-standard": "^16.0.3",
|
||||
"eslint-loader": "^4.0.2",
|
||||
"eslint-plugin-import": "^2.25.2",
|
||||
"eslint-plugin-jest": "^25.2.2",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"eslint-plugin-prettier": "5.2.1",
|
||||
"eslint-plugin-promise": "^5.1.1",
|
||||
"eslint-plugin-vue": "^7.20.0",
|
||||
"eslint-plugin-vue": "8.7.1",
|
||||
"gradido-config": "../config",
|
||||
"joi": "^17.13.3",
|
||||
"jsdom": "^25.0.0",
|
||||
"mock-apollo-client": "^1.2.1",
|
||||
"postcss": "^8.4.8",
|
||||
"postcss-html": "^1.3.0",
|
||||
"postcss-scss": "^4.0.3",
|
||||
"stylelint": "^14.5.3",
|
||||
"stylelint-config-recommended-vue": "^1.3.0",
|
||||
"stylelint-config-standard-scss": "^3.0.0",
|
||||
"vue-cli-plugin-i18n": "^2.3.1",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
"prettier": "^3.3.3",
|
||||
"stylelint": "16.7.0",
|
||||
"stylelint-config-recommended-vue": "1.5.0",
|
||||
"stylelint-config-standard-scss": "13.1.0",
|
||||
"unplugin-icons": "^0.19.0",
|
||||
"unplugin-vue-components": "^0.27.3",
|
||||
"vite-plugin-environment": "^1.1.3",
|
||||
"vite-plugin-graphql-loader": "^4.0.4",
|
||||
"vitest": "^2.0.5",
|
||||
"vitest-canvas-mock": "^0.3.3"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
@ -92,5 +102,10 @@
|
||||
"ignore": [
|
||||
"**/*.spec.js"
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
"strip-ansi": "6.0.1",
|
||||
"string-width": "4.2.2",
|
||||
"wrap-ansi": "7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
7
admin/prepare-and-build.sh
Executable file
@ -0,0 +1,7 @@
|
||||
# TODO this is the quick&dirty solution for the openssl security topic, please see https://stackoverflow.com/questions/69692842/error-message-error0308010cdigital-envelope-routinesunsupported
|
||||
$env:NODE_OPTIONS = "--openssl-legacy-provider"
|
||||
|
||||
nvm use
|
||||
yarn cache clean
|
||||
yarn install
|
||||
yarn build
|
||||
BIN
admin/public/img/Crea.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
admin/public/img/Crea.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
@ -1,11 +1,12 @@
|
||||
// Imports
|
||||
import CONFIG from '../src/config'
|
||||
|
||||
const express = require('express')
|
||||
const path = require('path')
|
||||
|
||||
// Host & Port
|
||||
const hostname = '127.0.0.1'
|
||||
const port = process.env.PORT || 8080
|
||||
|
||||
const hostname = CONFIG.ADMIN_MODULE_HOST // '127.0.0.1'
|
||||
const port = CONFIG.ADMIN_MODULE_PORT // process.env.PORT || 8080
|
||||
// Express Server
|
||||
const app = express()
|
||||
// Serve files
|
||||
|
||||
@ -1,34 +1,63 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import App from './App'
|
||||
import { createStore } from 'vuex'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import defaultLayout from '@/layouts/defaultLayout'
|
||||
|
||||
const localVue = global.localVue
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div>Mock Route</div>' } }],
|
||||
})
|
||||
|
||||
const stubs = {
|
||||
RouterView: true,
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$store: {
|
||||
state: {
|
||||
token: null,
|
||||
const createVuexStore = (initialState = { token: null }) => {
|
||||
return createStore({
|
||||
state() {
|
||||
return initialState
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
describe('App.vue', () => {
|
||||
let store
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return shallowMount(App, { localVue, stubs, mocks })
|
||||
const createWrapper = (token = null) => {
|
||||
store = createVuexStore({ token })
|
||||
return shallowMount(App, {
|
||||
global: {
|
||||
plugins: [store, router],
|
||||
stubs: {
|
||||
BToastOrchestrator: true,
|
||||
BModalOrchestrator: true,
|
||||
defaultLayout: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('shallowMount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('has a div with id "app"', () => {
|
||||
expect(wrapper.find('div#app').exists()).toBeTruthy()
|
||||
})
|
||||
it('div#app is present', () => {
|
||||
expect(wrapper.find('div#app').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders default layout when token is present', () => {
|
||||
wrapper = createWrapper('some-token')
|
||||
|
||||
expect(wrapper.findComponent(defaultLayout).exists()).toBe(true)
|
||||
expect(wrapper.find('router-view-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render defaultLayout when token is not present', () => {
|
||||
expect(wrapper.findComponent(defaultLayout).exists()).toBe(false)
|
||||
expect(wrapper.find('router-view-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('always renders BToastOrchestrator and BModalOrchestrator', () => {
|
||||
expect(wrapper.findComponent({ name: 'BToastOrchestrator' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'BModalOrchestrator' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,23 +1,22 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<BToastOrchestrator />
|
||||
<default-layout v-if="$store.state.token" />
|
||||
<router-view v-else></router-view>
|
||||
<BModalOrchestrator />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import defaultLayout from '@/layouts/defaultLayout'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: { defaultLayout },
|
||||
}
|
||||
import { BModalOrchestrator } from 'bootstrap-vue-next'
|
||||
</script>
|
||||
<style>
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pointer:hover {
|
||||
background-color: rgb(216, 213, 213);
|
||||
background-color: rgb(216 213 213);
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
admin/src/assets/icons/circle-slash.png
Normal file
|
After Width: | Height: | Size: 1022 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
BIN
admin/src/assets/icons/envelope.png
Normal file
|
After Width: | Height: | Size: 491 B |
BIN
admin/src/assets/icons/person.png
Normal file
|
After Width: | Height: | Size: 396 B |
333
admin/src/components/AiChat.vue
Normal file
@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<b-button
|
||||
v-if="!isChatOpen"
|
||||
:class="['chat-toggle-button', 'bg-crea-img', { 'slide-up-animation': !hasBeenOpened }]"
|
||||
:variant="light"
|
||||
@click="openChat"
|
||||
></b-button>
|
||||
|
||||
<div v-if="isChatOpen" class="chat-window">
|
||||
<div class="d-flex justify-content-start">
|
||||
<b-button variant="light" class="chat-close-button mt-1 ms-1 btn-sm" @click="closeChat">
|
||||
<IIcBaselineClose />
|
||||
</b-button>
|
||||
</div>
|
||||
<div ref="chatContainer" class="messages-scroll-container">
|
||||
<TransitionGroup class="messages" tag="div" name="chat">
|
||||
<div v-for="(message, index) in messages" :key="index" :class="['message', message.role]">
|
||||
<div class="message-content position-relative inner-container">
|
||||
<span v-html="formatMessage(message)"></span>
|
||||
<b-button
|
||||
v-if="message.role === 'assistant'"
|
||||
variant="light"
|
||||
class="copy-clipboard-button"
|
||||
:title="$t('copy-to-clipboard')"
|
||||
@click="copyToClipboard(message.content)"
|
||||
>
|
||||
<IBiClipboard></IBiClipboard>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<!--<div class="d-flex justify-content-end position-absolute top-0 start-0">
|
||||
<b-button variant="light" class="chat-close-button mt-1 ms-1 btn-sm" @click="closeChat">
|
||||
<IIcBaselineClose />
|
||||
</b-button>
|
||||
</div> -->
|
||||
|
||||
<div class="input-area">
|
||||
<BFormTextarea
|
||||
v-model="newMessage"
|
||||
class="fs-6"
|
||||
:placeholder="textareaPlaceholder"
|
||||
rows="2"
|
||||
no-resize
|
||||
:disabled="loading"
|
||||
@keydown.ctrl.enter="sendMessage"
|
||||
@keydown.meta.enter="sendMessage"
|
||||
></BFormTextarea>
|
||||
<b-button variant="light" :disabled="loading" @click="sendMessage">
|
||||
{{ buttonText }}
|
||||
</b-button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-start">
|
||||
<b-button variant="light" class="chat-clear-button" @click="clearChat">
|
||||
{{ $t('ai.chat-clear') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
sendMessage as sendMessageMutation,
|
||||
resumeChat,
|
||||
deleteThread,
|
||||
} from '../graphql/aiChat.graphql'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { toastError, toastSuccess } = useAppToast()
|
||||
const response = useMutation(sendMessageMutation, { input: ref('') })
|
||||
const deleteResponse = useMutation(deleteThread, { threadId: ref('') })
|
||||
const { result: resumeChatResult, refetch: resumeChatRefetch } = useQuery(resumeChat)
|
||||
|
||||
const isChatOpen = ref(false)
|
||||
const chatContainer = ref(null)
|
||||
const newMessage = ref('')
|
||||
const threadId = ref('')
|
||||
const messages = ref([])
|
||||
const loading = ref(false)
|
||||
const hasBeenOpened = ref(false)
|
||||
const buttonText = computed(() => t('send') + (loading.value ? '...' : ''))
|
||||
const textareaPlaceholder = computed(() =>
|
||||
loading.value ? t('ai.chat-placeholder-loading') : t('ai.chat-placeholder'),
|
||||
)
|
||||
|
||||
function formatMessage(message) {
|
||||
return message.content.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
function copyToClipboard(content) {
|
||||
navigator.clipboard.writeText(content)
|
||||
toastSuccess(t('copied-to-clipboard'))
|
||||
}
|
||||
|
||||
function openChat() {
|
||||
isChatOpen.value = true
|
||||
if (messages.value.length > 0) {
|
||||
scrollDown()
|
||||
}
|
||||
}
|
||||
|
||||
function closeChat() {
|
||||
hasBeenOpened.value = true
|
||||
isChatOpen.value = false
|
||||
}
|
||||
|
||||
// clear
|
||||
function clearChat() {
|
||||
if (threadId.value && threadId.value.length > 0) {
|
||||
// delete thread on closing chat
|
||||
deleteResponse
|
||||
.mutate({ threadId: threadId.value })
|
||||
.then((result) => {
|
||||
threadId.value = ''
|
||||
messages.value = []
|
||||
if (result.data.deleteThread) {
|
||||
toastSuccess(t('ai.chat-thread-deleted'))
|
||||
newMessage.value = t('ai.start-prompt')
|
||||
sendMessage()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(t('ai.error-chat-thread-deleted', { error }))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scrollDown() {
|
||||
nextTick(() => {
|
||||
if (!chatContainer.value) return
|
||||
chatContainer.value.scrollTo({
|
||||
top: chatContainer.value.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const sendMessage = () => {
|
||||
if (newMessage.value.trim()) {
|
||||
loading.value = true
|
||||
if (newMessage.value !== t('ai.start-prompt')) {
|
||||
messages.value.push({ content: newMessage.value, role: 'user' })
|
||||
scrollDown()
|
||||
}
|
||||
response
|
||||
.mutate({ input: { message: newMessage.value, threadId: threadId.value } })
|
||||
.then(({ data }) => {
|
||||
if (data && data.sendMessage) {
|
||||
threadId.value = data.sendMessage.threadId
|
||||
messages.value.push(data.sendMessage)
|
||||
}
|
||||
loading.value = false
|
||||
scrollDown()
|
||||
})
|
||||
.catch((error) => {
|
||||
loading.value = false
|
||||
toastError('Error sending message:', error)
|
||||
})
|
||||
newMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (messages.value.length === 0) {
|
||||
loading.value = true
|
||||
await resumeChatRefetch()
|
||||
const messagesFromServer = resumeChatResult.value.resumeChat
|
||||
if (messagesFromServer && messagesFromServer.length > 0) {
|
||||
threadId.value = messagesFromServer[0].threadId
|
||||
messages.value = messagesFromServer.filter(
|
||||
(message) => message.content !== t('ai.start-prompt'),
|
||||
)
|
||||
scrollDown()
|
||||
loading.value = false
|
||||
} else {
|
||||
newMessage.value = t('ai.start-prompt')
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-toggle-button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border: 1px solid darkblue;
|
||||
}
|
||||
|
||||
.chat-clear-button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bg-crea-img {
|
||||
background-image: url('../../public/img/Crea.webp');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
width: 250px;
|
||||
height: 142px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
width: 550px;
|
||||
height: 330px;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgb(0 0 0 / 10%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.copy-clipboard-button {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
right: -12%;
|
||||
padding-top: 2px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.messages-scroll-container {
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
max-width: 80%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background-color: white;
|
||||
color: black;
|
||||
margin-left: auto;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background-color: #e9ecef;
|
||||
color: black;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #ccc;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.input-area textarea {
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.input-area button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Animations für den Einblendeffekt */
|
||||
.chat-enter-active,
|
||||
.chat-leave-active {
|
||||
transition:
|
||||
transform 0.5s ease-out,
|
||||
opacity 0.5s;
|
||||
}
|
||||
|
||||
.chat-enter-from {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-enter-to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-up-animation {
|
||||
animation: slide-up 1s ease-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,22 +1,23 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ChangeUserRoleFormular from './ChangeUserRoleFormular'
|
||||
import { setUserRole } from '../graphql/setUserRole'
|
||||
import { toastSuccessSpy, toastErrorSpy } from '../../test/testSetup'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import ChangeUserRoleFormular from './ChangeUserRoleFormular.vue'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { useStore } from 'vuex'
|
||||
|
||||
const localVue = global.localVue
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
setUserRole: null,
|
||||
},
|
||||
})
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
$store: {
|
||||
vi.mock('vuex', () => ({
|
||||
useStore: vi.fn(() => ({
|
||||
state: {
|
||||
moderator: {
|
||||
id: 0,
|
||||
@ -24,648 +25,225 @@ const mocks = {
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useAppToast: () => ({
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockBFormSelect = {
|
||||
name: 'BFormSelect',
|
||||
template: '<select data-testid="mock-bformselect"><slot></slot></select>',
|
||||
props: ['modelValue', 'options'],
|
||||
}
|
||||
const mockBButton = {
|
||||
name: 'BButton',
|
||||
template: '<button data-testid="mock-bbutton"><slot></slot></button>',
|
||||
}
|
||||
|
||||
let propsData
|
||||
let wrapper
|
||||
let spy
|
||||
|
||||
describe('ChangeUserRoleFormular', () => {
|
||||
const Wrapper = () => {
|
||||
return mount(ChangeUserRoleFormular, { localVue, mocks, propsData })
|
||||
let wrapper
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return mount(ChangeUserRoleFormular, {
|
||||
props: propsData,
|
||||
global: {
|
||||
stubs: {
|
||||
BFormSelect: mockBFormSelect,
|
||||
BButton: mockBButton,
|
||||
},
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
describe('DOM elements', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
describe('DOM has', () => {
|
||||
it('has a DIV element with the class change-user-role-formular', () => {
|
||||
expect(wrapper.find('.change-user-role-formular').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('change own role', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 0,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
}
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('has the text that you cannot change own role', () => {
|
||||
expect(wrapper.text()).toContain('userRole.notChangeYourSelf')
|
||||
})
|
||||
|
||||
it('has no role select', () => {
|
||||
expect(wrapper.find('[data-testid="mock-bformselect"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has no button', () => {
|
||||
expect(wrapper.find('[data-testid="mock-bbutton"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("change other user's role", () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('has no text that you cannot change own role', () => {
|
||||
expect(wrapper.text()).not.toContain('userRole.notChangeYourSelf')
|
||||
})
|
||||
|
||||
it('has the select label', () => {
|
||||
expect(wrapper.text()).toContain('userRole.selectLabel')
|
||||
})
|
||||
|
||||
it('has a select', () => {
|
||||
expect(wrapper.find('[data-testid="mock-bformselect"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has "change_user_role" button', () => {
|
||||
const button = wrapper.find('[data-testid="mock-bbutton"]')
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.text()).toBe('change_user_role')
|
||||
})
|
||||
|
||||
describe('user has role "usual user"', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
propsData.item.roles = ['USER']
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('has a DIV element with the class.delete-user-formular', () => {
|
||||
expect(wrapper.find('.change-user-role-formular').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('change own role', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 0,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
it('has selected option set to "USER"', () => {
|
||||
expect(wrapper.vm.roleSelected).toBe('USER')
|
||||
})
|
||||
|
||||
it('has the text that you cannot change own role', () => {
|
||||
expect(wrapper.text()).toContain('userRole.notChangeYourSelf')
|
||||
})
|
||||
|
||||
it('has no role select', () => {
|
||||
expect(wrapper.find('select.role-select').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has no button', () => {
|
||||
expect(wrapper.find('button.btn.btn-dange').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("change other user's role", () => {
|
||||
let rolesToSelect
|
||||
|
||||
describe('general', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
rolesToSelect = wrapper.find('select.role-select').findAll('option')
|
||||
describe('change select to new role "MODERATOR"', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper.vm.roleSelected = 'MODERATOR'
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('has no text that you cannot change own role', () => {
|
||||
expect(wrapper.text()).not.toContain('userRole.notChangeYourSelf')
|
||||
it('has "change_user_role" button enabled', () => {
|
||||
const button = wrapper.find('[data-testid="mock-bbutton"]')
|
||||
expect(button.attributes('disabled')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('has the select label', () => {
|
||||
expect(wrapper.text()).toContain('userRole.selectLabel')
|
||||
})
|
||||
|
||||
it('has a select', () => {
|
||||
expect(wrapper.find('select.role-select').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has role select enabled', () => {
|
||||
expect(wrapper.find('select.role-select[disabled="disabled"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has "change_user_role" button', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger').text()).toBe('change_user_role')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has role "usual user"', () => {
|
||||
beforeEach(() => {
|
||||
apolloMutateMock.mockResolvedValue({
|
||||
data: {
|
||||
setUserRole: 'ADMIN',
|
||||
},
|
||||
})
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: ['USER'],
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
rolesToSelect = wrapper.find('select.role-select').findAll('option')
|
||||
})
|
||||
|
||||
it('has selected option set to "usual user"', () => {
|
||||
expect(wrapper.find('select.role-select').element.value).toBe('USER')
|
||||
})
|
||||
|
||||
describe('change select to', () => {
|
||||
describe('same role', () => {
|
||||
it('has "change_user_role" button disabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not call the API', () => {
|
||||
rolesToSelect.at(0).setSelected()
|
||||
expect(apolloMutateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
describe('clicking the "change_user_role" button', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('[data-testid="mock-bbutton"]').trigger('click')
|
||||
})
|
||||
|
||||
describe('new role "MODERATOR"', () => {
|
||||
beforeEach(() => {
|
||||
rolesToSelect.at(1).setSelected()
|
||||
})
|
||||
|
||||
it('has "change_user_role" button enabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger').exists()).toBe(true)
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
describe('clicking the "change_user_role" button', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('calls the modal', () => {
|
||||
expect(wrapper.emitted('showModal'))
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('confirm role change with success', () => {
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: setUserRole,
|
||||
variables: {
|
||||
userId: 1,
|
||||
role: 'MODERATOR',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits "updateRoles" with role moderator', () => {
|
||||
expect(wrapper.emitted('updateRoles')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
roles: ['MODERATOR'],
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('toasts success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('userRole.successfullyChangedTo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm role change with error', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('new role "ADMIN"', () => {
|
||||
beforeEach(() => {
|
||||
rolesToSelect.at(2).setSelected()
|
||||
})
|
||||
|
||||
it('has "change_user_role" button enabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger').exists()).toBe(true)
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
describe('clicking the "change_user_role" button', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('calls the modal', () => {
|
||||
expect(wrapper.emitted('showModal'))
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('confirm role change with success', () => {
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: setUserRole,
|
||||
variables: {
|
||||
userId: 1,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits "updateRoles" with role moderator', () => {
|
||||
expect(wrapper.emitted('updateRoles')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('toasts success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('userRole.successfullyChangedTo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm role change with error', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
it('emits "show-modal" event', () => {
|
||||
expect(wrapper.emitted('show-modal')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has role "moderator"', () => {
|
||||
beforeEach(() => {
|
||||
apolloMutateMock.mockResolvedValue({
|
||||
data: {
|
||||
setUserRole: null,
|
||||
},
|
||||
})
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: ['MODERATOR'],
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
rolesToSelect = wrapper.find('select.role-select').findAll('option')
|
||||
})
|
||||
|
||||
it('has selected option set to "MODERATOR"', () => {
|
||||
expect(wrapper.find('select.role-select').element.value).toBe('MODERATOR')
|
||||
})
|
||||
|
||||
describe('change select to', () => {
|
||||
describe('same role', () => {
|
||||
it('has "change_user_role" button disabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not call the API', () => {
|
||||
rolesToSelect.at(1).setSelected()
|
||||
expect(apolloMutateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('new role "USER"', () => {
|
||||
beforeEach(() => {
|
||||
rolesToSelect.at(0).setSelected()
|
||||
})
|
||||
|
||||
it('has "change_user_role" button enabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger').exists()).toBe(true)
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
describe('clicking the "change_user_role" button', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('calls the modal', () => {
|
||||
expect(wrapper.emitted('showModal'))
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('confirm role change with success', () => {
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: setUserRole,
|
||||
variables: {
|
||||
userId: 1,
|
||||
role: 'USER',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits "updateRoles"', () => {
|
||||
expect(wrapper.emitted('updateRoles')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
roles: [],
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('toasts success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('userRole.successfullyChangedTo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm role change with error', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('new role "ADMIN"', () => {
|
||||
beforeEach(() => {
|
||||
rolesToSelect.at(2).setSelected()
|
||||
})
|
||||
|
||||
it('has "change_user_role" button enabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger').exists()).toBe(true)
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
describe('clicking the "change_user_role" button', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('calls the modal', () => {
|
||||
expect(wrapper.emitted('showModal'))
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('confirm role change with success', () => {
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: setUserRole,
|
||||
variables: {
|
||||
userId: 1,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits "updateRoles"', () => {
|
||||
expect(wrapper.emitted('updateRoles')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('toasts success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('userRole.successfullyChangedTo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm role change with error', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has role "admin"', () => {
|
||||
beforeEach(() => {
|
||||
apolloMutateMock.mockResolvedValue({
|
||||
data: {
|
||||
setUserRole: null,
|
||||
},
|
||||
})
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
rolesToSelect = wrapper.find('select.role-select').findAll('option')
|
||||
})
|
||||
|
||||
it('has selected option set to "admin"', () => {
|
||||
expect(wrapper.find('select.role-select').element.value).toBe('ADMIN')
|
||||
})
|
||||
|
||||
describe('change select to', () => {
|
||||
describe('same role', () => {
|
||||
it('has "change_user_role" button disabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not call the API', () => {
|
||||
rolesToSelect.at(1).setSelected()
|
||||
// TODO: Fix this
|
||||
expect(apolloMutateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('new role "USER"', () => {
|
||||
beforeEach(() => {
|
||||
rolesToSelect.at(0).setSelected()
|
||||
})
|
||||
|
||||
it('has "change_user_role" button enabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger').exists()).toBe(true)
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
describe('clicking the "change_user_role" button', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('calls the modal', () => {
|
||||
expect(wrapper.emitted('showModal'))
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('confirm role change with success', () => {
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: setUserRole,
|
||||
variables: {
|
||||
userId: 1,
|
||||
role: 'USER',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits "updateRoles"', () => {
|
||||
expect(wrapper.emitted('updateRoles')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
roles: [],
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('toasts success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('userRole.successfullyChangedTo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm role change with error', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('new role "MODERATOR"', () => {
|
||||
beforeEach(() => {
|
||||
rolesToSelect.at(1).setSelected()
|
||||
})
|
||||
|
||||
it('has "change_user_role" button enabled', () => {
|
||||
expect(wrapper.find('button.btn.btn-danger').exists()).toBe(true)
|
||||
expect(wrapper.find('button.btn.btn-danger[disabled="disabled"]').exists()).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
describe('clicking the "change_user_role" button', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('calls the modal', () => {
|
||||
expect(wrapper.emitted('showModal'))
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('confirm role change with success', () => {
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: setUserRole,
|
||||
variables: {
|
||||
userId: 1,
|
||||
role: 'MODERATOR',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits "updateRoles"', () => {
|
||||
expect(wrapper.emitted('updateRoles')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
roles: ['MODERATOR'],
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('toasts success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('userRole.successfullyChangedTo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm role change with error', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated user is MODERATOR', () => {
|
||||
beforeEach(() => {
|
||||
mocks.$store.state.moderator.roles = ['MODERATOR']
|
||||
})
|
||||
|
||||
it('displays text with role', () => {
|
||||
expect(wrapper.text()).toBe('userRole.selectRoles.admin')
|
||||
})
|
||||
|
||||
it('has no role select', () => {
|
||||
expect(wrapper.find('select.role-select').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has no button', () => {
|
||||
expect(wrapper.find('button.btn.btn-dange').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated user is MODERATOR', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useStore).mockReturnValue({
|
||||
state: {
|
||||
moderator: {
|
||||
id: 0,
|
||||
name: 'test moderator',
|
||||
roles: ['MODERATOR'],
|
||||
},
|
||||
},
|
||||
})
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('has no role select', () => {
|
||||
expect(wrapper.find('[data-testid="mock-bformselect"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has no button', () => {
|
||||
expect(wrapper.find('[data-testid="mock-bbutton"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUserRole method', () => {
|
||||
let mockMutate
|
||||
|
||||
beforeEach(() => {
|
||||
mockMutate = vi.fn()
|
||||
useMutation.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
})
|
||||
|
||||
propsData = {
|
||||
item: {
|
||||
userId: 1,
|
||||
roles: ['USER'],
|
||||
},
|
||||
}
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('calls setUserRole mutation and emits update-roles on success', async () => {
|
||||
mockMutate.mockResolvedValue({ data: { setUserRole: 'MODERATOR' } })
|
||||
|
||||
await wrapper.vm.updateUserRole('MODERATOR', 'USER')
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
role: 'MODERATOR',
|
||||
})
|
||||
expect(wrapper.emitted('update-roles')).toBeTruthy()
|
||||
expect(wrapper.emitted('update-roles')[0]).toEqual([
|
||||
{
|
||||
userId: 1,
|
||||
roles: ['MODERATOR'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('handles error and resets role on failure', async () => {
|
||||
mockMutate.mockRejectedValue(new Error('API Error'))
|
||||
|
||||
await wrapper.vm.updateUserRole('MODERATOR', 'USER')
|
||||
|
||||
expect(mockMutate).toHaveBeenCalled()
|
||||
expect(wrapper.vm.roleSelected).toBe('USER')
|
||||
expect(wrapper.emitted('update-roles')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,124 +1,102 @@
|
||||
<template>
|
||||
<div class="change-user-role-formular">
|
||||
<div class="shadow p-3 mb-5 bg-white rounded">
|
||||
<div v-if="!$store.state.moderator.roles.includes('ADMIN')" class="m-3 mb-4">
|
||||
{{ roles.find((role) => role.value === currentRole).text }}
|
||||
<div v-if="!isModeratorRoleAdmin" class="m-3 mb-4">
|
||||
{{ roles.find((role) => role.value === currentRole.value)?.text }}
|
||||
</div>
|
||||
<div v-else-if="item.userId === $store.state.moderator.id" class="m-3 mb-4">
|
||||
<div v-else-if="item.userId === moderatorId" class="m-3 mb-4">
|
||||
{{ $t('userRole.notChangeYourSelf') }}
|
||||
</div>
|
||||
<div v-else class="m-3">
|
||||
<label for="role" class="mr-3">{{ $t('userRole.selectLabel') }}</label>
|
||||
<b-form-select class="role-select" v-model="roleSelected" :options="roles" />
|
||||
<label for="role" class="me-3">{{ $t('userRole.selectLabel') }}</label>
|
||||
<BFormSelect v-model="roleSelected" class="role-select" :options="roles" />
|
||||
<div class="mt-3 mb-5">
|
||||
<b-button
|
||||
variant="danger"
|
||||
v-b-modal.user-role-modal
|
||||
:disabled="currentRole === roleSelected"
|
||||
@click="showModal()"
|
||||
>
|
||||
<BButton variant="danger" @click="showModal">
|
||||
<!-- :disabled="currentRole.value === roleSelected.value" -->
|
||||
{{ $t('change_user_role') }}
|
||||
</b-button>
|
||||
</BButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { setUserRole } from '../graphql/setUserRole'
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BButton, BFormSelect } from 'bootstrap-vue-next'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { setUserRole as setUserRoleMutation } from '../graphql/setUserRole'
|
||||
import { useStore } from 'vuex'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
const { toastError, toastSuccess } = useAppToast()
|
||||
|
||||
const rolesValues = {
|
||||
ADMIN: 'ADMIN',
|
||||
MODERATOR: 'MODERATOR',
|
||||
MODERATOR_AI: 'MODERATOR_AI',
|
||||
USER: 'USER',
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ChangeUserRoleFormular',
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentRole: this.getCurrentRole(),
|
||||
roleSelected: this.getCurrentRole(),
|
||||
roles: [
|
||||
{ value: rolesValues.USER, text: this.$t('userRole.selectRoles.user') },
|
||||
{ value: rolesValues.MODERATOR, text: this.$t('userRole.selectRoles.moderator') },
|
||||
{ value: rolesValues.ADMIN, text: this.$t('userRole.selectRoles.admin') },
|
||||
],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCurrentRole() {
|
||||
if (this.item.roles.length) return rolesValues[this.item.roles[0]]
|
||||
return rolesValues.USER
|
||||
},
|
||||
showModal() {
|
||||
this.$bvModal
|
||||
.msgBoxConfirm(
|
||||
this.$t('overlay.changeUserRole.question', {
|
||||
username: `${this.item.firstName} ${this.item.lastName}`,
|
||||
newRole:
|
||||
this.roleSelected === rolesValues.ADMIN
|
||||
? this.$t('userRole.selectRoles.admin')
|
||||
: this.roleSelected === rolesValues.MODERATOR
|
||||
? this.$t('userRole.selectRoles.moderator')
|
||||
: this.$t('userRole.selectRoles.user'),
|
||||
}),
|
||||
{
|
||||
cancelTitle: this.$t('overlay.cancel'),
|
||||
centered: true,
|
||||
hideHeaderClose: true,
|
||||
title: this.$t('overlay.changeUserRole.title'),
|
||||
okTitle: this.$t('overlay.changeUserRole.yes'),
|
||||
okVariant: 'danger',
|
||||
},
|
||||
)
|
||||
.then((okClicked) => {
|
||||
if (okClicked) {
|
||||
this.setUserRole(this.roleSelected, this.currentRole)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
setUserRole(newRole, oldRole) {
|
||||
const role = this.roles.find((role) => {
|
||||
return role.value === newRole
|
||||
})
|
||||
const roleText = role.text
|
||||
const roleValue = role.value
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: setUserRole,
|
||||
variables: {
|
||||
userId: this.item.userId,
|
||||
role: role.value,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.$emit('updateRoles', {
|
||||
userId: this.item.userId,
|
||||
roles: roleValue === 'USER' ? [] : [roleValue],
|
||||
})
|
||||
this.toastSuccess(
|
||||
this.$t('userRole.successfullyChangedTo', {
|
||||
role: roleText,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.roleSelected = oldRole
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const getCurrentRole = () => {
|
||||
if (props.item.roles.length) return rolesValues[props.item.roles[0]]
|
||||
return rolesValues.USER
|
||||
}
|
||||
const currentRole = ref(getCurrentRole())
|
||||
const roleSelected = ref(getCurrentRole())
|
||||
|
||||
const emit = defineEmits(['update-roles', 'show-modal', 'select-role'])
|
||||
const isModeratorRoleAdmin = computed(() => store.state.moderator.roles.includes('ADMIN'))
|
||||
const moderatorId = computed(() => store.state.moderator.id)
|
||||
|
||||
const roles = computed(() => [
|
||||
{ value: rolesValues.USER, text: t('userRole.selectRoles.user') },
|
||||
{ value: rolesValues.MODERATOR, text: t('userRole.selectRoles.moderator') },
|
||||
{ value: rolesValues.MODERATOR_AI, text: t('userRole.selectRoles.moderatorAi') },
|
||||
{ value: rolesValues.ADMIN, text: t('userRole.selectRoles.admin') },
|
||||
])
|
||||
|
||||
const showModal = async () => {
|
||||
emit('show-modal')
|
||||
}
|
||||
|
||||
const { mutate: setUserRole } = useMutation(setUserRoleMutation)
|
||||
|
||||
const updateUserRole = (newRole, oldRole) => {
|
||||
const role = roles.value.find((role) => role.value === newRole)
|
||||
const roleText = role.text
|
||||
const roleValue = role.value
|
||||
|
||||
setUserRole({
|
||||
userId: props.item.userId,
|
||||
role: role.value,
|
||||
})
|
||||
.then(() => {
|
||||
emit('update-roles', {
|
||||
userId: props.item.userId,
|
||||
roles: roleValue === 'USER' ? [] : [roleValue],
|
||||
})
|
||||
toastSuccess(
|
||||
t('userRole.successfullyChangedTo', {
|
||||
role: roleText,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
roleSelected.value = oldRole
|
||||
toastError(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ currentRole, roleSelected, updateUserRole })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
17
admin/src/components/CollapseIcon.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="collapse-icon">
|
||||
<IBiArrowUpCircle v-if="visible" class="text-black h2" />
|
||||
<IBiArrowDownCircle v-else class="text-muted h2" />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'CollapseIcon',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -1,69 +1,72 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ConfirmRegisterMailFormular from './ConfirmRegisterMailFormular'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import ConfirmRegisterMailFormular from './ConfirmRegisterMailFormular.vue'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
checked: false,
|
||||
email: 'bob@baumeister.de',
|
||||
dateLastSend: '',
|
||||
}
|
||||
vi.mock('@vue/apollo-composable')
|
||||
vi.mock('vue-i18n')
|
||||
vi.mock('@/composables/useToast')
|
||||
|
||||
describe('ConfirmRegisterMailFormular', () => {
|
||||
let wrapper
|
||||
const mockMutate = vi.fn()
|
||||
const mockT = vi.fn((key) => key)
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ConfirmRegisterMailFormular, { localVue, mocks, propsData })
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
beforeEach(() => {
|
||||
useMutation.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
})
|
||||
|
||||
it('has a DIV element with the class.component-confirm-register-mail', () => {
|
||||
expect(wrapper.find('.component-confirm-register-mail').exists()).toBeTruthy()
|
||||
useI18n.mockReturnValue({
|
||||
t: mockT,
|
||||
})
|
||||
|
||||
describe('send register mail with success', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find('button.test-button').trigger('click')
|
||||
})
|
||||
useAppToast.mockReturnValue({
|
||||
toastSuccess: mockToastSuccess,
|
||||
toastError: mockToastError,
|
||||
})
|
||||
|
||||
it('calls the API with email', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: { email: 'bob@baumeister.de' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
wrapper = mount(ConfirmRegisterMailFormular, {
|
||||
props: {
|
||||
checked: false,
|
||||
email: 'bob@baumeister.de',
|
||||
dateLastSend: '',
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: mockT,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('unregister_mail.success')
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.component-confirm-register-mail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('send register mail', () => {
|
||||
it('calls the API with email on button click', async () => {
|
||||
mockMutate.mockResolvedValueOnce({})
|
||||
await wrapper.find('button.test-button').trigger('click')
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
email: 'bob@baumeister.de',
|
||||
})
|
||||
})
|
||||
|
||||
describe('send register mail with error', () => {
|
||||
beforeEach(() => {
|
||||
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('button.test-button').trigger('click')
|
||||
})
|
||||
it('shows success message on successful API call', async () => {
|
||||
mockMutate.mockResolvedValueOnce({})
|
||||
await wrapper.find('button.test-button').trigger('click')
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('unregister_mail.success')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('unregister_mail.error')
|
||||
})
|
||||
it('shows error message on failed API call', async () => {
|
||||
mockMutate.mockRejectedValueOnce(new Error('OUCH!'))
|
||||
await wrapper.find('button.test-button').trigger('click')
|
||||
expect(mockToastError).toHaveBeenCalledWith('unregister_mail.error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,64 +1,64 @@
|
||||
<template>
|
||||
<div class="component-confirm-register-mail">
|
||||
<div class="shadow p-3 mb-5 bg-white rounded">
|
||||
<div v-if="checked">{{ $t('unregister_mail.text_true') }}</div>
|
||||
<div v-if="props.checked">{{ $t('unregister_mail.text_true') }}</div>
|
||||
<div v-else>
|
||||
{{
|
||||
dateLastSend === ''
|
||||
? $t('unregister_mail.never_sent', { email })
|
||||
: $t('unregister_mail.text_false', { date: dateLastSend, email })
|
||||
props.dateLastSend === ''
|
||||
? $t('unregister_mail.never_sent', { email: props.email })
|
||||
: $t('unregister_mail.text_false', { date: props.dateLastSend, email: props.email })
|
||||
}}
|
||||
|
||||
<!-- Using components -->
|
||||
<b-input-group :prepend="$t('unregister_mail.info')" class="mt-3">
|
||||
<b-form-input readonly :value="email"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="outline-success" class="test-button" @click="sendRegisterMail">
|
||||
{{ $t('unregister_mail.button') }}
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<BInputGroup :prepend="$t('unregister_mail.info')" class="mt-3">
|
||||
<BFormInput v-model="email" readonly />
|
||||
<BButton variant="outline-success" append class="test-button" @click="sendRegisterMail">
|
||||
{{ $t('unregister_mail.button') }}
|
||||
</BButton>
|
||||
</BInputGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { sendActivationEmail } from '../graphql/sendActivationEmail'
|
||||
import { BButton, BFormInput, BInputGroup } from 'bootstrap-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
export default {
|
||||
name: 'ConfirmRegisterMail',
|
||||
props: {
|
||||
checked: {
|
||||
type: Boolean,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
},
|
||||
dateLastSend: {
|
||||
type: String,
|
||||
},
|
||||
const props = defineProps({
|
||||
checked: {
|
||||
type: Boolean,
|
||||
},
|
||||
methods: {
|
||||
sendRegisterMail() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: sendActivationEmail,
|
||||
variables: {
|
||||
email: this.email,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.toastSuccess(this.$t('unregister_mail.success', { email: this.email }))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(this.$t('unregister_mail.error', { message: error.message }))
|
||||
})
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
},
|
||||
dateLastSend: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { toastError, toastSuccess } = useAppToast()
|
||||
|
||||
const email = ref(props.email)
|
||||
|
||||
const { mutate: activateEmail } = useMutation(sendActivationEmail)
|
||||
|
||||
const sendRegisterMail = async () => {
|
||||
try {
|
||||
await activateEmail({
|
||||
email: email.value,
|
||||
})
|
||||
toastSuccess(t('unregister_mail.success', { email: email.value }))
|
||||
} catch (error) {
|
||||
toastError(t('unregister_mail.error', { message: error.message }))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.input-group-text {
|
||||
background-color: rgb(255, 252, 205);
|
||||
background-color: rgb(255 252 205);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,29 +1,15 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContentFooter from './ContentFooter'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$i18n: {
|
||||
locale: jest.fn(() => 'en'),
|
||||
},
|
||||
}
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import ContentFooter from './ContentFooter.vue'
|
||||
|
||||
describe('ContentFooter', () => {
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ContentFooter, { localVue, mocks })
|
||||
}
|
||||
beforeEach(() => {
|
||||
wrapper = mount(ContentFooter, {})
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders the div element ".content-footer"', () => {
|
||||
expect(wrapper.find('div.content-footer').exists()).toBe(true)
|
||||
})
|
||||
it('renders the footer', () => {
|
||||
expect(wrapper.find('.content-footer').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
<template>
|
||||
<hr class="mb-0" />
|
||||
<div class="content-footer">
|
||||
<hr />
|
||||
<b-row align-v="center" class="mt-4 mb-4 justify-content-lg-between">
|
||||
<b-col>
|
||||
<BTr class="mt-4 mb-4 justify-content-lg-between">
|
||||
<BCol>
|
||||
<div class="copyright text-center text-lg-center text-muted">
|
||||
{{ $t('footer.copyright.year', { year }) }}
|
||||
<a
|
||||
:href="`https://gradido.net/${$i18n.locale}`"
|
||||
class="font-weight-bold ml-1"
|
||||
class="fw-bold ms-1 link-underline link-underline-opacity-0 link-underline-opacity-100-hover"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('footer.copyright.link') }}
|
||||
</a>
|
||||
{{ $t('math.pipe') }}
|
||||
<a href="https://github.com/gradido/gradido/releases/latest" target="_blank">
|
||||
|
|
||||
<a
|
||||
href="https://github.com/gradido/gradido/releases/latest"
|
||||
target="_blank"
|
||||
class="link-underline link-underline-opacity-0 link-underline-opacity-100-hover"
|
||||
>
|
||||
{{ $t('footer.app_version', { version }) }}
|
||||
</a>
|
||||
<a
|
||||
@ -24,22 +28,23 @@
|
||||
{{ $t('footer.short_hash', { shortHash }) }}
|
||||
</a>
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</BCol>
|
||||
</BTr>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
<script setup>
|
||||
import CONFIG from '../config'
|
||||
import { BTr, BCol } from 'bootstrap-vue-next'
|
||||
|
||||
export default {
|
||||
name: 'ContentFooter',
|
||||
data() {
|
||||
return {
|
||||
year: new Date().getFullYear(),
|
||||
version: CONFIG.APP_VERSION,
|
||||
hash: CONFIG.BUILD_COMMIT,
|
||||
shortHash: CONFIG.BUILD_COMMIT_SHORT,
|
||||
}
|
||||
},
|
||||
}
|
||||
const year = new Date().getFullYear()
|
||||
const version = CONFIG.APP_VERSION
|
||||
const hash = CONFIG.BUILD_COMMIT
|
||||
const shortHash = CONFIG.BUILD_COMMIT_SHORT
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.content-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,114 +1,125 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContributionLink from './ContributionLink'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import ContributionLink from './ContributionLink.vue'
|
||||
import { BButton, BCard, BCardText, BCollapse } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Meditation',
|
||||
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
|
||||
amount: '200',
|
||||
validFrom: '2022-04-01',
|
||||
validTo: '2022-08-01',
|
||||
cycle: 'täglich',
|
||||
maxPerCycle: '3',
|
||||
maxAmountPerMonth: 0,
|
||||
link: 'https://localhost/redeem/CL-1a2345678',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
}
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Meditation',
|
||||
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
|
||||
amount: '200',
|
||||
validFrom: '2022-04-01',
|
||||
validTo: '2022-08-01',
|
||||
cycle: 'täglich',
|
||||
maxPerCycle: '3',
|
||||
maxAmountPerMonth: 0,
|
||||
link: 'https://localhost/redeem/CL-1a2345678',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ContributionLink', () => {
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ContributionLink, { localVue, mocks, propsData })
|
||||
const createWrapper = () => {
|
||||
return mount(ContributionLink, {
|
||||
props: {
|
||||
items: mockItems,
|
||||
count: 1,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$d: (d) => d,
|
||||
},
|
||||
stubs: {
|
||||
BCard,
|
||||
BButton,
|
||||
BCollapse,
|
||||
BCardText,
|
||||
ContributionLinkForm: true,
|
||||
ContributionLinkList: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('renders the Div Element ".contribution-link"', () => {
|
||||
expect(wrapper.find('div.contribution-link').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has ContributionLinkList component when count > 0', () => {
|
||||
expect(wrapper.findComponent({ name: 'ContributionLinkList' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows "no contribution links" message when count is 0', async () => {
|
||||
await wrapper.setProps({ count: 0 })
|
||||
expect(wrapper.text()).toContain('contributionLink.noContributionLinks')
|
||||
})
|
||||
|
||||
it('has contribution form not visible by default', () => {
|
||||
expect(wrapper.vm.visible).toBe(false)
|
||||
})
|
||||
|
||||
describe('click on create new contribution', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('[data-test="new-contribution-link-button"]').trigger('click')
|
||||
})
|
||||
|
||||
it('shows the contribution form', () => {
|
||||
expect(wrapper.vm.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the form when clicked again', async () => {
|
||||
await wrapper.find('[data-test="new-contribution-link-button"]').trigger('click')
|
||||
expect(wrapper.vm.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit contribution link', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper.vm.editContributionLinkData(mockItems[0])
|
||||
})
|
||||
|
||||
it('shows the contribution form', () => {
|
||||
expect(wrapper.vm.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('sets editContributionLink to true', () => {
|
||||
expect(wrapper.vm.editContributionLink).toBe(true)
|
||||
})
|
||||
|
||||
it('sets contributionLinkData', () => {
|
||||
expect(wrapper.vm.contributionLinkData).toEqual(mockItems[0])
|
||||
})
|
||||
|
||||
it('hides new contribution button', () => {
|
||||
expect(wrapper.find('[data-test="new-contribution-link-button"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeContributionForm', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.visible = true
|
||||
wrapper.vm.editContributionLink = true
|
||||
wrapper.vm.contributionLinkData = mockItems[0]
|
||||
wrapper.vm.closeContributionForm()
|
||||
})
|
||||
|
||||
it('renders the Div Element ".contribution-link"', () => {
|
||||
expect(wrapper.find('div.contribution-link').exists()).toBe(true)
|
||||
it('hides the form', () => {
|
||||
expect(wrapper.vm.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('has one contribution link in table', () => {
|
||||
expect(wrapper.find('div.contribution-link-list').find('tbody').findAll('tr')).toHaveLength(1)
|
||||
it('resets editContributionLink', () => {
|
||||
expect(wrapper.vm.editContributionLink).toBe(false)
|
||||
})
|
||||
|
||||
it('has contribution form not visible by default', () => {
|
||||
expect(wrapper.find('#newContribution').isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
describe('click on create new contribution', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('[data-test="new-contribution-link-button"]').trigger('click')
|
||||
})
|
||||
|
||||
it('shows the contribution form', () => {
|
||||
expect(wrapper.find('#newContribution').isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
describe('click on create new contribution again', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('[data-test="new-contribution-link-button"]').trigger('click')
|
||||
})
|
||||
|
||||
it('closes the contribution form', () => {
|
||||
expect(wrapper.find('#newContribution').isVisible()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click on close button', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('button.btn-secondary').trigger('click')
|
||||
})
|
||||
|
||||
it('closes the contribution form', () => {
|
||||
expect(wrapper.find('#newContribution').isVisible()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit contribution link', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper
|
||||
.find('div.contribution-link-list')
|
||||
.find('tbody')
|
||||
.findAll('tr')
|
||||
.at(0)
|
||||
.findAll('button')
|
||||
.at(1)
|
||||
.trigger('click')
|
||||
})
|
||||
|
||||
it('shows the contribution form', () => {
|
||||
expect(wrapper.find('#newContribution').isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show the new contribution button', () => {
|
||||
expect(wrapper.find('[data-test="new-contribution-link-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('click on close button', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('button.btn-secondary').trigger('click')
|
||||
})
|
||||
|
||||
it('closes the contribution form', () => {
|
||||
expect(wrapper.find('#newContribution').isVisible()).toBe(false)
|
||||
})
|
||||
})
|
||||
it('resets contributionLinkData', () => {
|
||||
expect(wrapper.vm.contributionLinkData).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="contribution-link">
|
||||
<b-card
|
||||
<BCard
|
||||
border-variant="success"
|
||||
:header="$t('contributionLink.contributionLinks')"
|
||||
header-bg-variant="success"
|
||||
@ -8,40 +8,41 @@
|
||||
header-class="text-center"
|
||||
class="mt-5"
|
||||
>
|
||||
<b-button
|
||||
<BButton
|
||||
v-if="!editContributionLink"
|
||||
@click="visible = !visible"
|
||||
class="my-3 d-flex justify-content-left"
|
||||
data-test="new-contribution-link-button"
|
||||
@click="visible = !visible"
|
||||
>
|
||||
{{ $t('math.plus') }} {{ $t('contributionLink.newContributionLink') }}
|
||||
</b-button>
|
||||
</BButton>
|
||||
|
||||
<b-collapse v-model="visible" id="newContribution" class="mt-2">
|
||||
<b-card>
|
||||
<p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p>
|
||||
<BCollapse id="newContribution" v-model="visible" class="mt-2">
|
||||
<BCard>
|
||||
<p class="h2 ms-5">{{ $t('contributionLink.contributionLinks') }}</p>
|
||||
<contribution-link-form
|
||||
:contributionLinkData="contributionLinkData"
|
||||
:editContributionLink="editContributionLink"
|
||||
:contribution-link-data="contributionLinkData"
|
||||
:edit-contribution-link="editContributionLink"
|
||||
@get-contribution-links="$emit('get-contribution-links')"
|
||||
@closeContributionForm="closeContributionForm"
|
||||
@close-contribution-form="closeContributionForm"
|
||||
/>
|
||||
</b-card>
|
||||
</b-collapse>
|
||||
</BCard>
|
||||
</BCollapse>
|
||||
|
||||
<b-card-text>
|
||||
<BCardText>
|
||||
<contribution-link-list
|
||||
v-if="count > 0"
|
||||
:items="items"
|
||||
@editContributionLinkData="editContributionLinkData"
|
||||
@edit-contribution-link-data="editContributionLinkData"
|
||||
@get-contribution-links="$emit('get-contribution-links')"
|
||||
@closeContributionForm="closeContributionForm"
|
||||
@close-contribution-form="closeContributionForm"
|
||||
/>
|
||||
<div v-else>{{ $t('contributionLink.noContributionLinks') }}</div>
|
||||
</b-card-text>
|
||||
</b-card>
|
||||
</BCardText>
|
||||
</BCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ContributionLinkForm from '../ContributionLink/ContributionLinkForm'
|
||||
import ContributionLinkList from '../ContributionLink/ContributionLinkList'
|
||||
@ -62,6 +63,7 @@ export default {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['get-contribution-links'],
|
||||
data: function () {
|
||||
return {
|
||||
visible: false,
|
||||
@ -72,14 +74,14 @@ export default {
|
||||
methods: {
|
||||
closeContributionForm() {
|
||||
if (this.visible) {
|
||||
this.$root.$emit('bv::toggle::collapse', 'newContribution')
|
||||
this.visible = false
|
||||
this.editContributionLink = false
|
||||
this.contributionLinkData = {}
|
||||
}
|
||||
},
|
||||
editContributionLinkData(data) {
|
||||
if (!this.visible) {
|
||||
this.$root.$emit('bv::toggle::collapse', 'newContribution')
|
||||
this.visible = true
|
||||
}
|
||||
this.contributionLinkData = data
|
||||
this.editContributionLink = true
|
||||
|
||||
@ -1,144 +1,153 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContributionLinkForm from './ContributionLinkForm'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../../test/testSetup'
|
||||
import { createContributionLink } from '@/graphql/createContributionLink.js'
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import ContributionLinkForm from './ContributionLinkForm.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
// Mock external dependencies
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
global.alert = jest.fn()
|
||||
const mockMutate = vi.fn()
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useMutation: () => ({
|
||||
mutate: mockMutate,
|
||||
}),
|
||||
}))
|
||||
|
||||
const propsData = {
|
||||
contributionLinkData: {},
|
||||
editContributionLink: false,
|
||||
const mockToastError = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useAppToast: () => ({
|
||||
toastError: mockToastError,
|
||||
toastSuccess: mockToastSuccess,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
}
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
}
|
||||
|
||||
// const mockAPIcall = jest.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
}))
|
||||
|
||||
describe('ContributionLinkForm', () => {
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ContributionLinkForm, { localVue, mocks, propsData })
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(ContributionLinkForm, {
|
||||
props: {
|
||||
contributionLinkData: {},
|
||||
editContributionLink: false,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
},
|
||||
stubs: {
|
||||
BForm: true,
|
||||
BRow: true,
|
||||
BCol: true,
|
||||
BFormGroup: true,
|
||||
BFormInput: true,
|
||||
BFormTextarea: true,
|
||||
BFormSelect: true,
|
||||
BButton: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('renders the Div Element ".contribution-link-form"', () => {
|
||||
expect(wrapper.find('div.contribution-link-form').exists()).toBe(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('call onReset', () => {
|
||||
it('form has the set data', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setData({
|
||||
form: {
|
||||
name: 'name',
|
||||
memo: 'memo',
|
||||
amount: 100,
|
||||
validFrom: 'validFrom',
|
||||
validTo: 'validTo',
|
||||
cycle: 'ONCE',
|
||||
maxPerCycle: 1,
|
||||
maxAmountPerMonth: 100,
|
||||
},
|
||||
})
|
||||
wrapper.vm.onReset()
|
||||
})
|
||||
expect(wrapper.vm.form).toEqual({
|
||||
amount: null,
|
||||
cycle: 'ONCE',
|
||||
validTo: null,
|
||||
maxAmountPerMonth: '0',
|
||||
memo: null,
|
||||
name: null,
|
||||
maxPerCycle: 1,
|
||||
validFrom: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
it('renders the Div Element ".contribution-link-form"', () => {
|
||||
expect(wrapper.find('div.contribution-link-form').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('call onSubmit', () => {
|
||||
it('response with the contribution link url', () => {
|
||||
wrapper.vm.onSubmit()
|
||||
})
|
||||
})
|
||||
|
||||
describe('successfull submit', () => {
|
||||
beforeEach(async () => {
|
||||
apolloMutateMock.mockResolvedValue({
|
||||
data: {
|
||||
createContributionLink: {
|
||||
link: 'https://localhost/redeem/CL-1a2345678',
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper
|
||||
.findAllComponents({ name: 'BFormDatepicker' })
|
||||
.at(0)
|
||||
.vm.$emit('input', '2022-6-18')
|
||||
await wrapper
|
||||
.findAllComponents({ name: 'BFormDatepicker' })
|
||||
.at(1)
|
||||
.vm.$emit('input', '2022-7-18')
|
||||
await wrapper.find('input.test-name').setValue('test name')
|
||||
await wrapper.find('textarea.test-memo').setValue('test memo')
|
||||
await wrapper.find('input.test-amount').setValue('100')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toHaveBeenCalledWith({
|
||||
mutation: createContributionLink,
|
||||
variables: {
|
||||
validFrom: '2022-6-18',
|
||||
validTo: '2022-7-18',
|
||||
name: 'test name',
|
||||
amount: '100',
|
||||
memo: 'test memo',
|
||||
cycle: 'ONCE',
|
||||
maxPerCycle: 1,
|
||||
maxAmountPerMonth: '0',
|
||||
id: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts a succes message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('https://localhost/redeem/CL-1a2345678')
|
||||
})
|
||||
})
|
||||
|
||||
describe('send createContributionLink with error', () => {
|
||||
beforeEach(async () => {
|
||||
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
|
||||
await wrapper
|
||||
.findAllComponents({ name: 'BFormDatepicker' })
|
||||
.at(0)
|
||||
.vm.$emit('input', '2022-6-18')
|
||||
await wrapper
|
||||
.findAllComponents({ name: 'BFormDatepicker' })
|
||||
.at(1)
|
||||
.vm.$emit('input', '2022-7-18')
|
||||
await wrapper.find('input.test-name').setValue('test name')
|
||||
await wrapper.find('textarea.test-memo').setValue('test memo')
|
||||
await wrapper.find('input.test-amount').setValue('100')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('OUCH!')
|
||||
describe('onReset', () => {
|
||||
it('resets the form data', async () => {
|
||||
wrapper.vm.form = {
|
||||
name: 'name',
|
||||
memo: 'memo',
|
||||
amount: 100,
|
||||
validFrom: 'validFrom',
|
||||
validTo: 'validTo',
|
||||
cycle: 'ONCE',
|
||||
maxPerCycle: 1,
|
||||
maxAmountPerMonth: 100,
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.onReset()
|
||||
expect(wrapper.vm.form).toEqual({
|
||||
validTo: null,
|
||||
validFrom: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSubmit', () => {
|
||||
const validFormData = {
|
||||
validFrom: '2022-6-18',
|
||||
validTo: '2022-7-18',
|
||||
name: 'test name',
|
||||
memo: 'test memo',
|
||||
amount: '100',
|
||||
cycle: 'ONCE',
|
||||
maxPerCycle: 1,
|
||||
maxAmountPerMonth: '0',
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
wrapper.vm.form = validFormData
|
||||
})
|
||||
|
||||
it('calls the API and toasts success message on successful submission', async () => {
|
||||
mockMutate.mockResolvedValue({
|
||||
data: {
|
||||
createContributionLink: {
|
||||
link: 'https://localhost/redeem/CL-1a2345678',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.vm.onSubmit()
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
...validFormData,
|
||||
id: null,
|
||||
})
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('https://localhost/redeem/CL-1a2345678')
|
||||
})
|
||||
|
||||
it('toasts an error message on API error', async () => {
|
||||
mockMutate.mockRejectedValue({ message: 'OUCH!' })
|
||||
|
||||
await wrapper.vm.onSubmit()
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('OUCH!')
|
||||
})
|
||||
|
||||
it('shows error when validFrom is not set', async () => {
|
||||
wrapper.vm.form = { ...validFormData, validFrom: null }
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.onSubmit()
|
||||
expect(mockToastError).toHaveBeenCalledWith('contributionLink.noStartDate')
|
||||
})
|
||||
|
||||
it('shows error when validTo is not set', async () => {
|
||||
wrapper.vm.form = { ...validFormData, validTo: null }
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.onSubmit()
|
||||
expect(mockToastError).toHaveBeenCalledWith('contributionLink.noEndDate')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,41 +1,43 @@
|
||||
<template>
|
||||
<div class="contribution-link-form">
|
||||
<b-form class="m-5" @submit.prevent="onSubmit" ref="contributionLinkForm">
|
||||
<BForm ref="contributionLinkForm" class="m-5" @submit.prevent="onSubmit" @reset="onReset">
|
||||
<!-- Date -->
|
||||
<b-row>
|
||||
<b-col>
|
||||
<b-form-group :label="$t('contributionLink.validFrom')">
|
||||
<b-form-datepicker
|
||||
reset-button
|
||||
<BRow>
|
||||
<BCol>
|
||||
<BFormGroup :label="$t('contributionLink.validFrom')">
|
||||
<BFormInput
|
||||
v-model="form.validFrom"
|
||||
reset-button
|
||||
size="lg"
|
||||
:min="min"
|
||||
class="mb-4 test-validFrom"
|
||||
reset-value=""
|
||||
:label-no-date-selected="$t('contributionLink.noDateSelected')"
|
||||
required
|
||||
></b-form-datepicker>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
<b-col>
|
||||
<b-form-group :label="$t('contributionLink.validTo')">
|
||||
<b-form-datepicker
|
||||
reset-button
|
||||
type="date"
|
||||
/>
|
||||
</BFormGroup>
|
||||
</BCol>
|
||||
<BCol>
|
||||
<BFormGroup :label="$t('contributionLink.validTo')">
|
||||
<BFormInput
|
||||
v-model="form.validTo"
|
||||
reset-button
|
||||
size="lg"
|
||||
:min="form.validFrom ? form.validFrom : min"
|
||||
class="mb-4 test-validTo"
|
||||
reset-value=""
|
||||
:label-no-date-selected="$t('contributionLink.noDateSelected')"
|
||||
required
|
||||
></b-form-datepicker>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
</b-row>
|
||||
type="date"
|
||||
/>
|
||||
</BFormGroup>
|
||||
</BCol>
|
||||
</BRow>
|
||||
|
||||
<!-- Name -->
|
||||
<b-form-group :label="$t('contributionLink.name')">
|
||||
<b-form-input
|
||||
<BFormGroup :label="$t('contributionLink.name')">
|
||||
<BFormInput
|
||||
v-model="form.name"
|
||||
size="lg"
|
||||
type="text"
|
||||
@ -43,174 +45,174 @@
|
||||
required
|
||||
maxlength="100"
|
||||
class="test-name"
|
||||
></b-form-input>
|
||||
</b-form-group>
|
||||
></BFormInput>
|
||||
</BFormGroup>
|
||||
<!-- Desc -->
|
||||
<b-form-group :label="$t('contributionLink.memo')">
|
||||
<b-form-textarea
|
||||
<BFormGroup :label="$t('contributionLink.memo')">
|
||||
<BFormTextarea
|
||||
v-model="form.memo"
|
||||
size="lg"
|
||||
:placeholder="$t('contributionLink.memo')"
|
||||
required
|
||||
maxlength="255"
|
||||
class="test-memo"
|
||||
></b-form-textarea>
|
||||
</b-form-group>
|
||||
></BFormTextarea>
|
||||
</BFormGroup>
|
||||
<!-- Amount -->
|
||||
<b-form-group :label="$t('contributionLink.amount')">
|
||||
<b-form-input
|
||||
<BFormGroup :label="$t('contributionLink.amount')">
|
||||
<BFormInput
|
||||
v-model="form.amount"
|
||||
size="lg"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
required
|
||||
class="test-amount"
|
||||
></b-form-input>
|
||||
</b-form-group>
|
||||
<b-row class="mb-4">
|
||||
<b-col>
|
||||
></BFormInput>
|
||||
</BFormGroup>
|
||||
<BRow class="mb-4">
|
||||
<BCol>
|
||||
<!-- Cycle -->
|
||||
<label for="cycle">{{ $t('contributionLink.cycle') }}</label>
|
||||
<b-form-select
|
||||
v-model="form.cycle"
|
||||
:options="cycle"
|
||||
class="mb-3"
|
||||
size="lg"
|
||||
></b-form-select>
|
||||
</b-col>
|
||||
<b-col>
|
||||
<BFormSelect v-model="form.cycle" :options="cycle" class="mb-3" size="lg"></BFormSelect>
|
||||
</BCol>
|
||||
<BCol>
|
||||
<!-- maxPerCycle -->
|
||||
<label for="maxPerCycle">{{ $t('contributionLink.maxPerCycle') }}</label>
|
||||
<b-form-select
|
||||
<BFormSelect
|
||||
v-model="form.maxPerCycle"
|
||||
:options="maxPerCycle"
|
||||
:disabled="disabled"
|
||||
disabled
|
||||
class="mb-3"
|
||||
size="lg"
|
||||
></b-form-select>
|
||||
</b-col>
|
||||
</b-row>
|
||||
></BFormSelect>
|
||||
</BCol>
|
||||
</BRow>
|
||||
|
||||
<!-- Max amount -->
|
||||
<!--
|
||||
<b-form-group :label="$t('contributionLink.maximumAmount')">
|
||||
<b-form-input
|
||||
<BFormGroup :label="$t('contributionLink.maximumAmount')">
|
||||
<BFormInput
|
||||
v-model="form.maxAmountPerMonth"
|
||||
size="lg"
|
||||
:disabled="disabled"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
></b-form-input>
|
||||
</b-form-group>
|
||||
></BFormInput>
|
||||
</BFormGroup>
|
||||
-->
|
||||
<div class="mt-6">
|
||||
<b-button type="submit" variant="primary">
|
||||
<BButton type="submit" variant="primary" class="me-2">
|
||||
{{
|
||||
editContributionLink ? $t('contributionLink.saveChange') : $t('contributionLink.create')
|
||||
}}
|
||||
</b-button>
|
||||
<b-button type="reset" variant="danger" @click.prevent="onReset">
|
||||
</BButton>
|
||||
<BButton type="reset" variant="danger" class="me-2">
|
||||
{{ $t('contributionLink.clear') }}
|
||||
</b-button>
|
||||
<b-button @click.prevent="$emit('closeContributionForm')">
|
||||
{{ $t('contributionLink.close') }}
|
||||
</b-button>
|
||||
</BButton>
|
||||
<BButton @click.prevent="emit('close-contribution-form')">
|
||||
{{ $t('close') }}
|
||||
</BButton>
|
||||
{{ console.log(editContributionLink) }}
|
||||
</div>
|
||||
</b-form>
|
||||
</BForm>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { createContributionLink } from '@/graphql/createContributionLink.js'
|
||||
import { updateContributionLink } from '@/graphql/updateContributionLink.js'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDateFormatter } from '@/composables/useDateFormatter'
|
||||
|
||||
export default {
|
||||
name: 'ContributionLinkForm',
|
||||
props: {
|
||||
contributionLinkData: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
editContributionLink: { type: Boolean, required: true },
|
||||
const props = defineProps({
|
||||
contributionLinkData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
name: null,
|
||||
memo: null,
|
||||
amount: null,
|
||||
validFrom: null,
|
||||
validTo: null,
|
||||
cycle: 'ONCE',
|
||||
maxPerCycle: 1,
|
||||
maxAmountPerMonth: '0',
|
||||
},
|
||||
min: new Date(),
|
||||
cycle: [
|
||||
{ value: 'ONCE', text: this.$t('contributionLink.options.cycle.once') },
|
||||
// { value: 'hourly', text: this.$t('contributionLink.options.cycle.hourly') },
|
||||
{ value: 'DAILY', text: this.$t('contributionLink.options.cycle.daily') },
|
||||
// { value: 'weekly', text: this.$t('contributionLink.options.cycle.weekly') },
|
||||
// { value: 'monthly', text: this.$t('contributionLink.options.cycle.monthly') },
|
||||
// { value: 'yearly', text: this.$t('contributionLink.options.cycle.yearly') },
|
||||
],
|
||||
maxPerCycle: [
|
||||
{ value: '1', text: '1 x' },
|
||||
// { value: '2', text: '2 x' },
|
||||
// { value: '3', text: '3 x' },
|
||||
// { value: '4', text: '4 x' },
|
||||
// { value: '5', text: '5 x' },
|
||||
],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
if (this.form.validFrom === null)
|
||||
return this.toastError(this.$t('contributionLink.noStartDate'))
|
||||
if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate'))
|
||||
editContributionLink: { type: Boolean, required: true },
|
||||
})
|
||||
|
||||
const variables = {
|
||||
...this.form,
|
||||
id: this.contributionLinkData.id ? this.contributionLinkData.id : null,
|
||||
}
|
||||
const emit = defineEmits(['get-contribution-links', 'close-contribution-form'])
|
||||
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: this.editContributionLink ? updateContributionLink : createContributionLink,
|
||||
variables: variables,
|
||||
})
|
||||
.then((result) => {
|
||||
const link = this.editContributionLink
|
||||
? result.data.updateContributionLink.link
|
||||
: result.data.createContributionLink.link
|
||||
this.toastSuccess(
|
||||
this.editContributionLink ? this.$t('contributionLink.changeSaved') : link,
|
||||
)
|
||||
this.onReset()
|
||||
this.$root.$emit('bv::toggle::collapse', 'newContribution')
|
||||
this.$emit('get-contribution-links')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
onReset() {
|
||||
this.$refs.contributionLinkForm.reset()
|
||||
this.form = {}
|
||||
this.form.validFrom = null
|
||||
this.form.validTo = null
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
disabled() {
|
||||
return true
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
contributionLinkData() {
|
||||
this.form = this.contributionLinkData
|
||||
},
|
||||
const { t } = useI18n()
|
||||
|
||||
const { formatDateFromDateTime } = useDateFormatter()
|
||||
|
||||
const contributionLinkForm = ref(null)
|
||||
|
||||
const form = ref({
|
||||
name: null,
|
||||
memo: null,
|
||||
amount: null,
|
||||
validFrom: null,
|
||||
validTo: null,
|
||||
cycle: 'ONCE',
|
||||
maxAmountPerMonth: '0',
|
||||
})
|
||||
|
||||
const min = new Date().toLocaleDateString()
|
||||
const { toastError, toastSuccess } = useAppToast()
|
||||
|
||||
const cycle = ref([
|
||||
{ value: 'ONCE', text: t('contributionLink.options.cycle.once') },
|
||||
{ value: 'DAILY', text: t('contributionLink.options.cycle.daily') },
|
||||
])
|
||||
|
||||
const maxPerCycle = ref([{ value: '1', text: '1 x' }])
|
||||
|
||||
const { mutate: contributionLinkMutation } = useMutation(createContributionLink)
|
||||
|
||||
const { mutate: contributionLinkMutationUpdate } = useMutation(updateContributionLink)
|
||||
|
||||
watch(
|
||||
() => props.contributionLinkData,
|
||||
(newVal) => {
|
||||
form.value = newVal
|
||||
form.value.validFrom = formatDateFromDateTime(newVal.validFrom)
|
||||
form.value.validTo = formatDateFromDateTime(newVal.validTo)
|
||||
},
|
||||
)
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (form.value.validFrom === null) return toastError(t('contributionLink.noStartDate'))
|
||||
|
||||
if (form.value.validTo === null) return toastError(t('contributionLink.noEndDate'))
|
||||
|
||||
const variables = {
|
||||
...form.value,
|
||||
// maxAmountPerMonth: 1, // TODO this is added only for test puropuse during migration since max amount input is commented out but without it being a number bigger then 0 it doesn't work
|
||||
id: props.contributionLinkData.id ? props.contributionLinkData.id : null,
|
||||
}
|
||||
|
||||
try {
|
||||
const mutationType = props.editContributionLink
|
||||
? contributionLinkMutationUpdate
|
||||
: contributionLinkMutation
|
||||
const result = await mutationType({ ...variables })
|
||||
const link = props.editContributionLink
|
||||
? result.data.updateContributionLink.link
|
||||
: result.data.createContributionLink.link
|
||||
toastSuccess(props.editContributionLink ? t('contributionLink.changeSaved') : link)
|
||||
onReset()
|
||||
emit('close-contribution-form')
|
||||
emit('get-contribution-links')
|
||||
} catch (error) {
|
||||
toastError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.value = { validFrom: null, validTo: null }
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
form,
|
||||
min,
|
||||
cycle,
|
||||
maxPerCycle,
|
||||
onSubmit,
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,147 +1,122 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContributionLinkList from './ContributionLinkList'
|
||||
import { toastSuccessSpy, toastErrorSpy } from '../../../test/testSetup'
|
||||
// import { deleteContributionLink } from '../graphql/deleteContributionLink'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import ContributionLinkList from './ContributionLinkList.vue'
|
||||
import { BButton, BCard, BCardText, BModal, BTable } from 'bootstrap-vue-next'
|
||||
import * as apolloComposable from '@vue/apollo-composable'
|
||||
|
||||
const localVue = global.localVue
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key) => key,
|
||||
d: (date) => date.toISOString(),
|
||||
})),
|
||||
}))
|
||||
|
||||
const mockAPIcall = jest.fn()
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$apollo: {
|
||||
mutate: mockAPIcall,
|
||||
},
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Meditation',
|
||||
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
|
||||
amount: '200',
|
||||
validFrom: '2022-04-01',
|
||||
validTo: '2022-08-01',
|
||||
cycle: 'täglich',
|
||||
maxPerCycle: '3',
|
||||
maxAmountPerMonth: 0,
|
||||
link: 'https://localhost/redeem/CL-1a2345678',
|
||||
},
|
||||
],
|
||||
}
|
||||
// Mock useAppToast
|
||||
const mockToastError = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useAppToast: vi.fn(() => ({
|
||||
toastError: mockToastError,
|
||||
toastSuccess: mockToastSuccess,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('ContributionLinkList', () => {
|
||||
let wrapper
|
||||
let mutateMock
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ContributionLinkList, { localVue, mocks, propsData })
|
||||
const createWrapper = () => {
|
||||
return mount(ContributionLinkList, {
|
||||
props: {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Meditation',
|
||||
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
|
||||
amount: '200',
|
||||
validFrom: '2022-04-01',
|
||||
validTo: '2022-08-01',
|
||||
cycle: 'täglich',
|
||||
maxPerCycle: '3',
|
||||
maxAmountPerMonth: 0,
|
||||
link: 'https://localhost/redeem/CL-1a2345678',
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
components: {
|
||||
BTable,
|
||||
BButton,
|
||||
BModal,
|
||||
BCard,
|
||||
BCardText,
|
||||
},
|
||||
stubs: {
|
||||
IBiTrash: true,
|
||||
IBiPencil: true,
|
||||
IBiEye: true,
|
||||
FigureQrCode: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mutateMock = vi.fn()
|
||||
vi.spyOn(apolloComposable, 'useMutation').mockReturnValue({ mutate: mutateMock })
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('renders the Div Element ".contribution-link-list"', () => {
|
||||
expect(wrapper.find('div.contribution-link-list').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders table with contribution link', () => {
|
||||
expect(wrapper.findComponent({ name: 'BTable' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('edit contribution link', () => {
|
||||
it('emits editContributionLinkData', async () => {
|
||||
await wrapper.vm.editContributionLink({ id: 1 })
|
||||
expect(wrapper.emitted('edit-contribution-link-data')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the Div Element ".contribution-link-list"', () => {
|
||||
expect(wrapper.find('div.contribution-link-list').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders table with contribution link', () => {
|
||||
expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(0).text()).toContain(
|
||||
'Meditation',
|
||||
)
|
||||
})
|
||||
|
||||
describe('edit contribution link', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.editContributionLink()
|
||||
})
|
||||
|
||||
it('emits editContributionLinkData', async () => {
|
||||
expect(wrapper.vm.$emit('editContributionLinkData')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete contribution link', () => {
|
||||
let spy
|
||||
|
||||
describe('delete contribution link', () => {
|
||||
describe('with success', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
wrapper.vm.deleteContributionLink()
|
||||
mutateMock.mockResolvedValue({})
|
||||
await wrapper.vm.handleDelete({ item: { id: 1, name: 'Test' } })
|
||||
await wrapper.vm.executeDelete()
|
||||
})
|
||||
|
||||
describe('with success', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve('some value'))
|
||||
mockAPIcall.mockResolvedValue()
|
||||
await wrapper.find('.test-delete-link').trigger('click')
|
||||
})
|
||||
|
||||
it('opens the modal ', () => {
|
||||
expect(spy).toBeCalled()
|
||||
})
|
||||
|
||||
it.skip('calls the API', () => {
|
||||
// expect(mockAPIcall).toBeCalledWith(
|
||||
// expect.objectContaining({
|
||||
// mutation: deleteContributionLink,
|
||||
// variables: {
|
||||
// id: 1,
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('contributionLink.deleted')
|
||||
})
|
||||
it('calls the mutation and emits events', async () => {
|
||||
expect(mutateMock).toHaveBeenCalledWith({ id: 1 })
|
||||
expect(wrapper.emitted('close-contribution-form')).toBeTruthy()
|
||||
expect(wrapper.emitted('get-contribution-links')).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('with error', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve('some value'))
|
||||
mockAPIcall.mockRejectedValue({ message: 'Something went wrong :(' })
|
||||
await wrapper.find('.test-delete-link').trigger('click')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Something went wrong :(')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel delete', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(false))
|
||||
mockAPIcall.mockResolvedValue()
|
||||
await wrapper.find('.test-delete-link').trigger('click')
|
||||
})
|
||||
|
||||
it('does not call the API', () => {
|
||||
expect(mockAPIcall).not.toBeCalled()
|
||||
})
|
||||
it('toasts a success message', () => {
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('contributionLink.deleted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onClick showButton', () => {
|
||||
it('modelData contains contribution link', () => {
|
||||
wrapper.find('button.test-show').trigger('click')
|
||||
expect(wrapper.vm.modalData).toEqual({
|
||||
amount: '200',
|
||||
cycle: 'täglich',
|
||||
id: 1,
|
||||
link: 'https://localhost/redeem/CL-1a2345678',
|
||||
maxAmountPerMonth: 0,
|
||||
maxPerCycle: '3',
|
||||
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
|
||||
name: 'Meditation',
|
||||
validFrom: '2022-04-01',
|
||||
validTo: '2022-08-01',
|
||||
})
|
||||
describe('with error', () => {
|
||||
beforeEach(async () => {
|
||||
mutateMock.mockRejectedValue(new Error('Something went wrong :('))
|
||||
await wrapper.vm.handleDelete({ item: { id: 1, name: 'Test' } })
|
||||
await wrapper.vm.executeDelete()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('Something went wrong :(')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,125 +1,151 @@
|
||||
<template>
|
||||
<div class="contribution-link-list">
|
||||
<b-table :items="items" :fields="fields" striped hover stacked="lg">
|
||||
<BTable :items="props.items" :fields="fields" striped hover stacked="lg">
|
||||
<template #cell(delete)="data">
|
||||
<b-button
|
||||
<BButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
class="mr-2 test-delete-link"
|
||||
@click="deleteContributionLink(data.item.id, data.item.name)"
|
||||
class="me-2 test-delete-link"
|
||||
@click="handleDelete(data)"
|
||||
>
|
||||
<b-icon icon="trash" variant="light"></b-icon>
|
||||
</b-button>
|
||||
<IBiTrash />
|
||||
</BButton>
|
||||
</template>
|
||||
<template #cell(edit)="data">
|
||||
<b-button variant="success" size="md" class="mr-2" @click="editContributionLink(data.item)">
|
||||
<b-icon icon="pencil" variant="light"></b-icon>
|
||||
</b-button>
|
||||
<BButton variant="success" size="md" class="me-2" @click="editContributionLink(data.item)">
|
||||
<IBiPencil />
|
||||
</BButton>
|
||||
</template>
|
||||
<template #cell(show)="data">
|
||||
<b-button
|
||||
<BButton
|
||||
variant="info"
|
||||
size="md"
|
||||
class="mr-2 test-show"
|
||||
class="me-2 test-show"
|
||||
@click="showContributionLink(data.item)"
|
||||
>
|
||||
<b-icon icon="eye" variant="light"></b-icon>
|
||||
</b-button>
|
||||
<IBiEye />
|
||||
</BButton>
|
||||
</template>
|
||||
</b-table>
|
||||
</BTable>
|
||||
|
||||
<b-modal ref="my-modal" ok-only hide-header-close>
|
||||
<b-card header-tag="header" footer-tag="footer">
|
||||
<BModal
|
||||
v-if="modalData"
|
||||
id="qr-link-modal"
|
||||
ref="my-modal"
|
||||
v-model="qrLinkModal"
|
||||
ok-only
|
||||
hide-header-close
|
||||
>
|
||||
<BCard header-tag="header" footer-tag="footer">
|
||||
<template #header>
|
||||
<h6 class="mb-0">{{ modalData ? modalData.name : '' }}</h6>
|
||||
</template>
|
||||
<b-card-text>
|
||||
<BCardText>
|
||||
{{ modalData.memo ? modalData.memo : '' }}
|
||||
<figure-qr-code :link="modalData ? modalData.link : ''" />
|
||||
</b-card-text>
|
||||
</BCardText>
|
||||
<template #footer>
|
||||
<em>{{ modalData ? modalData.link : '' }}</em>
|
||||
</template>
|
||||
</b-card>
|
||||
</b-modal>
|
||||
</BCard>
|
||||
</BModal>
|
||||
<BModal id="delete-link-modal" v-model="deleteLinkModal" @ok="executeDelete">
|
||||
<template #default>
|
||||
{{ t('contributionLink.deleteNow', { name: itemToBeDeleted.name }) }}
|
||||
</template>
|
||||
</BModal>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { deleteContributionLink } from '@/graphql/deleteContributionLink.js'
|
||||
import FigureQrCode from '../FigureQrCode'
|
||||
import { useModal } from 'bootstrap-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
export default {
|
||||
name: 'ContributionLinkList',
|
||||
components: {
|
||||
FigureQrCode,
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
items: { type: Array, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fields: [
|
||||
'name',
|
||||
'memo',
|
||||
'amount',
|
||||
{ key: 'cycle', label: this.$t('contributionLink.cycle') },
|
||||
{ key: 'maxPerCycle', label: this.$t('contributionLink.maxPerCycle') },
|
||||
{
|
||||
key: 'validFrom',
|
||||
label: this.$t('contributionLink.validFrom'),
|
||||
formatter: (value, key, item) => {
|
||||
if (value) {
|
||||
return this.$d(new Date(value))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'validTo',
|
||||
label: this.$t('contributionLink.validTo'),
|
||||
formatter: (value, key, item) => {
|
||||
if (value) {
|
||||
return this.$d(new Date(value))
|
||||
}
|
||||
},
|
||||
},
|
||||
'delete',
|
||||
'edit',
|
||||
'show',
|
||||
],
|
||||
modalData: {},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
deleteContributionLink(id, name) {
|
||||
this.$bvModal
|
||||
.msgBoxConfirm(this.$t('contributionLink.deleteNow', { name: name }))
|
||||
.then(async (value) => {
|
||||
if (value)
|
||||
await this.$apollo
|
||||
.mutate({
|
||||
mutation: deleteContributionLink,
|
||||
variables: {
|
||||
id: id,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.toastSuccess(this.$t('contributionLink.deleted'))
|
||||
this.$emit('closeContributionForm')
|
||||
this.$emit('get-contribution-links')
|
||||
})
|
||||
.catch((err) => {
|
||||
this.toastError(err.message)
|
||||
})
|
||||
})
|
||||
},
|
||||
editContributionLink(row) {
|
||||
this.$emit('editContributionLinkData', row)
|
||||
},
|
||||
})
|
||||
|
||||
showContributionLink(row) {
|
||||
this.modalData = row
|
||||
this.$refs['my-modal'].show()
|
||||
},
|
||||
const qrLinkModal = ref(false)
|
||||
const { show: showQrCodeModal } = useModal('qr-link-modal')
|
||||
|
||||
const deleteLinkModal = ref(false)
|
||||
const { show: showDeleteLinkModal } = useModal('delete-link-modal')
|
||||
|
||||
const emit = defineEmits([
|
||||
'close-contribution-form',
|
||||
'get-contribution-links',
|
||||
'edit-contribution-link-data',
|
||||
])
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const { toastError, toastSuccess } = useAppToast()
|
||||
|
||||
const modalData = ref({})
|
||||
|
||||
const fields = ref([
|
||||
'name',
|
||||
'memo',
|
||||
'amount',
|
||||
{ key: 'cycle', label: t('contributionLink.cycle') },
|
||||
{ key: 'maxPerCycle', label: t('contributionLink.maxPerCycle') },
|
||||
{
|
||||
key: 'validFrom',
|
||||
label: t('contributionLink.validFrom'),
|
||||
formatter: (value) => (value ? d(new Date(value)) : ''),
|
||||
},
|
||||
{
|
||||
key: 'validTo',
|
||||
label: t('contributionLink.validTo'),
|
||||
formatter: (value) => (value ? d(new Date(value)) : ''),
|
||||
},
|
||||
'delete',
|
||||
'edit',
|
||||
'show',
|
||||
])
|
||||
|
||||
const { mutate: deleteContributionLinkMutation } = useMutation(deleteContributionLink)
|
||||
|
||||
const itemToBeDeleted = ref({})
|
||||
|
||||
const handleDelete = async (dataPayload) => {
|
||||
itemToBeDeleted.value = { ...dataPayload.item }
|
||||
showDeleteLinkModal()
|
||||
}
|
||||
|
||||
const executeDelete = async () => {
|
||||
try {
|
||||
await deleteContributionLinkMutation({ id: parseInt(itemToBeDeleted.value.id) })
|
||||
toastSuccess(t('contributionLink.deleted'))
|
||||
emit('close-contribution-form')
|
||||
emit('get-contribution-links')
|
||||
itemToBeDeleted.value = {}
|
||||
} catch (err) {
|
||||
toastError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const editContributionLink = (row) => {
|
||||
emit('edit-contribution-link-data', row)
|
||||
}
|
||||
|
||||
const showContributionLink = (row) => {
|
||||
modalData.value = row
|
||||
showQrCodeModal()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
fields,
|
||||
modalData,
|
||||
deleteContributionLink,
|
||||
editContributionLink,
|
||||
showContributionLink,
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,247 +1,162 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import ContributionMessagesFormular from './ContributionMessagesFormular'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../../test/testSetup'
|
||||
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
|
||||
import { adminUpdateContribution } from '@/graphql/adminUpdateContribution'
|
||||
import { BButton, BForm } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
const mockToastError = vi.fn()
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useAppToast: () => ({
|
||||
toastError: mockToastError,
|
||||
toastSuccess: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue()
|
||||
const mockMutate = vi.fn().mockResolvedValue({})
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useMutation: () => ({
|
||||
mutate: mockMutate,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
locale: { value: 'en' },
|
||||
t: (key) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockChildComponents = {
|
||||
BForm,
|
||||
BFormGroup: { template: '<div><slot /></div>' },
|
||||
BFormCheckbox: { template: '<div></div>' },
|
||||
BFormInput: { template: '<input />' },
|
||||
BTabs: { template: '<div><slot /></div>' },
|
||||
BTab: { template: '<div><slot /></div>' },
|
||||
BTooltip: { template: '<div></div>' },
|
||||
BFormTextarea: { template: '<textarea></textarea>' },
|
||||
BRow: { template: '<div><slot /></div>' },
|
||||
BCol: { template: '<div><slot /></div>' },
|
||||
BButton,
|
||||
TimePicker: { template: '<div></div>' },
|
||||
}
|
||||
|
||||
describe('ContributionMessagesFormular', () => {
|
||||
let wrapper
|
||||
|
||||
const propsData = {
|
||||
contributionId: 42,
|
||||
contributionMemo: 'It is a test memo',
|
||||
hideResubmission: true,
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
$i18n: {
|
||||
locale: 'en',
|
||||
},
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(ContributionMessagesFormular, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
global: {
|
||||
components: mockChildComponents,
|
||||
mocks: {
|
||||
$route: {
|
||||
params: { id: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
contributionId: 42,
|
||||
contributionMemo: 'It is a test memo',
|
||||
hideResubmission: true,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has a DIV .contribution-messages-formular', () => {
|
||||
expect(wrapper.find('div.contribution-messages-formular').exists()).toBe(true)
|
||||
})
|
||||
it('renders the component', () => {
|
||||
wrapper = createWrapper()
|
||||
expect(wrapper.find('.contribution-messages-formular').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('on trigger reset', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper.setData({
|
||||
form: {
|
||||
text: 'text form message',
|
||||
},
|
||||
})
|
||||
await wrapper.find('form').trigger('reset')
|
||||
})
|
||||
it('resets form on reset event', async () => {
|
||||
wrapper = createWrapper()
|
||||
wrapper.vm.form.text = 'text form message'
|
||||
wrapper.vm.form.memo = 'changed memo'
|
||||
|
||||
it('form has empty text and memo reset to contribution memo input', () => {
|
||||
expect(wrapper.vm.form).toEqual({
|
||||
text: '',
|
||||
memo: 'It is a test memo',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('on trigger submit', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper.setData({
|
||||
form: {
|
||||
text: 'text form message',
|
||||
},
|
||||
})
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('emitted "get-list-contribution-messages" with data', async () => {
|
||||
expect(wrapper.emitted('get-list-contribution-messages')).toEqual(
|
||||
expect.arrayContaining([expect.arrayContaining([42])]),
|
||||
)
|
||||
})
|
||||
|
||||
it('emitted "update-status" with data', async () => {
|
||||
expect(wrapper.emitted('update-status')).toEqual(
|
||||
expect.arrayContaining([expect.arrayContaining([42])]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('send DIALOG contribution message with success', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({
|
||||
form: {
|
||||
text: 'text form message',
|
||||
},
|
||||
})
|
||||
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
|
||||
})
|
||||
|
||||
it('moderatorMessage has `DIALOG`', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith({
|
||||
mutation: adminCreateContributionMessage,
|
||||
variables: {
|
||||
contributionId: 42,
|
||||
message: 'text form message',
|
||||
messageType: 'DIALOG',
|
||||
resubmissionAt: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts an success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('message.request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('send MODERATOR contribution message with success', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({
|
||||
form: {
|
||||
text: 'text form message',
|
||||
},
|
||||
})
|
||||
|
||||
// choose tab
|
||||
// tabs: text | moderator | memo
|
||||
// 0 | 1 | 2
|
||||
await wrapper
|
||||
.find('div[data-test="message-type-tabs"]')
|
||||
.findAll('.nav-item a')
|
||||
.at(1)
|
||||
.trigger('click')
|
||||
|
||||
// click save
|
||||
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
|
||||
})
|
||||
|
||||
it('moderatorMesage has `MODERATOR`', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith({
|
||||
mutation: adminCreateContributionMessage,
|
||||
variables: {
|
||||
contributionId: 42,
|
||||
message: 'text form message',
|
||||
messageType: 'MODERATOR',
|
||||
resubmissionAt: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts an success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('message.request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('send resubmission contribution message with success', () => {
|
||||
const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days in milliseconds
|
||||
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({
|
||||
form: {
|
||||
text: 'text form message',
|
||||
},
|
||||
showResubmissionDate: true,
|
||||
resubmissionDate: futureDate,
|
||||
resubmissionTime: '08:46',
|
||||
})
|
||||
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
|
||||
})
|
||||
|
||||
it('graphql payload contain resubmission date', () => {
|
||||
const futureDateExactTime = futureDate
|
||||
futureDateExactTime.setHours(8)
|
||||
futureDateExactTime.setMinutes(46)
|
||||
expect(apolloMutateMock).toBeCalledWith({
|
||||
mutation: adminCreateContributionMessage,
|
||||
variables: {
|
||||
contributionId: 42,
|
||||
message: 'text form message',
|
||||
messageType: 'DIALOG',
|
||||
resubmissionAt: futureDateExactTime.toString(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts an success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('message.request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('set memo', () => {
|
||||
beforeEach(async () => {
|
||||
// choose tab
|
||||
// tabs: text | moderator | memo
|
||||
// 0 | 1 | 2
|
||||
await wrapper
|
||||
.find('div[data-test="message-type-tabs"]')
|
||||
.findAll('.nav-item a')
|
||||
.at(2)
|
||||
.trigger('click')
|
||||
|
||||
// click save
|
||||
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
|
||||
})
|
||||
it('check tabindex value is 2', () => {
|
||||
expect(wrapper.vm.tabindex).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update contribution memo from moderator for user created contributions', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({
|
||||
form: {
|
||||
memo: 'changed memo',
|
||||
},
|
||||
tabindex: 2,
|
||||
})
|
||||
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
|
||||
})
|
||||
|
||||
it('adminUpdateContribution was called with contributionId and updated memo', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith({
|
||||
mutation: adminUpdateContribution,
|
||||
variables: {
|
||||
id: 42,
|
||||
memo: 'changed memo',
|
||||
resubmissionAt: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts an success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('message.request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('send contribution message with error', () => {
|
||||
beforeEach(async () => {
|
||||
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
|
||||
wrapper = Wrapper()
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('OUCH!')
|
||||
})
|
||||
await wrapper.find('form').trigger('reset')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.form).toEqual({
|
||||
text: '',
|
||||
memo: 'It is a test memo',
|
||||
})
|
||||
})
|
||||
|
||||
it('submits form and emits events', async () => {
|
||||
wrapper = createWrapper()
|
||||
wrapper.vm.form.text = 'text form message'
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('get-list-contribution-messages')).toBeTruthy()
|
||||
expect(wrapper.emitted('get-list-contribution-messages')[0]).toEqual([42])
|
||||
expect(wrapper.emitted('update-status')).toBeTruthy()
|
||||
expect(wrapper.emitted('update-status')[0]).toEqual([42])
|
||||
})
|
||||
|
||||
it('sends DIALOG contribution message', async () => {
|
||||
wrapper = createWrapper()
|
||||
wrapper.vm.form.text = 'text form message'
|
||||
const onSubmitSpy = vi.spyOn(wrapper.vm, 'onSubmit')
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('button[type="submit"]').trigger('click')
|
||||
expect(onSubmitSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends MODERATOR contribution message', async () => {
|
||||
wrapper = createWrapper()
|
||||
const onSubmitSpy = vi.spyOn(wrapper.vm, 'onSubmit')
|
||||
|
||||
wrapper.vm.form.text = 'text form message'
|
||||
wrapper.vm.tabindex = 1
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('button[type="submit"]').trigger('click')
|
||||
expect(onSubmitSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends resubmission contribution message', async () => {
|
||||
const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
wrapper = createWrapper()
|
||||
const onSubmitSpy = vi.spyOn(wrapper.vm, 'onSubmit')
|
||||
wrapper.vm.form.text = 'text form message'
|
||||
wrapper.vm.showResubmissionDate = true
|
||||
wrapper.vm.resubmissionDate = futureDate
|
||||
wrapper.vm.resubmissionTime = '08:46'
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('button[type="submit"]').trigger('click')
|
||||
expect(onSubmitSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates contribution memo', async () => {
|
||||
wrapper = createWrapper()
|
||||
const onSubmitSpy = vi.spyOn(wrapper.vm, 'onSubmit')
|
||||
|
||||
wrapper.vm.form.memo = 'changed memo'
|
||||
wrapper.vm.tabindex = 2
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('button[type="submit"]').trigger('click')
|
||||
await nextTick()
|
||||
expect(onSubmitSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles error when sending contribution message', async () => {
|
||||
const mockError = new Error('OUCH!')
|
||||
wrapper = createWrapper()
|
||||
|
||||
mockMutate.mockRejectedValue(mockError)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await nextTick()
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('OUCH!')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,244 +1,251 @@
|
||||
<template>
|
||||
<div class="contribution-messages-formular">
|
||||
<div class="mt-5">
|
||||
<b-form @reset.prevent="onReset" @submit="onSubmit()">
|
||||
<b-form-group>
|
||||
<b-form-checkbox v-model="showResubmissionDate">
|
||||
<BForm @reset.prevent="onReset" @submit="onSubmit()">
|
||||
<BFormGroup>
|
||||
<BFormCheckbox v-model="showResubmissionDate">
|
||||
{{ $t('moderator.show-submission-form') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group v-if="showResubmissionDate">
|
||||
<b-form-datepicker v-model="resubmissionDate" :min="now"></b-form-datepicker>
|
||||
<time-picker v-model="resubmissionTime"></time-picker>
|
||||
</b-form-group>
|
||||
<b-tabs content-class="mt-3" v-model="tabindex" data-test="message-type-tabs">
|
||||
<b-tab active>
|
||||
</BFormCheckbox>
|
||||
</BFormGroup>
|
||||
<BFormGroup v-if="showResubmissionDate">
|
||||
<div class="d-flex my-2">
|
||||
<Datepicker
|
||||
v-model="resubmissionDate"
|
||||
:locale="dateLocale"
|
||||
input-format="P"
|
||||
:lower-limit="now"
|
||||
class="form-control"
|
||||
/>
|
||||
<time-picker v-model="resubmissionTime" class="ms-2" />
|
||||
</div>
|
||||
</BFormGroup>
|
||||
<BTabs v-model="tabindex" class="mt-3" content-class="mt-3" data-test="message-type-tabs">
|
||||
<BTab active>
|
||||
<template #title>
|
||||
<span id="message-tab-title">{{ $t('moderator.message') }}</span>
|
||||
<b-tooltip target="message-tab-title" triggers="hover">
|
||||
<BTooltip target="message-tab-title" triggers="hover">
|
||||
{{ $t('moderator.message-tooltip') }}
|
||||
</b-tooltip>
|
||||
</BTooltip>
|
||||
</template>
|
||||
<b-form-textarea
|
||||
<BFormTextarea
|
||||
id="textarea"
|
||||
v-model="form.text"
|
||||
:placeholder="$t('contributionLink.memo')"
|
||||
rows="3"
|
||||
></b-form-textarea>
|
||||
</b-tab>
|
||||
<b-tab>
|
||||
/>
|
||||
</BTab>
|
||||
<BTab>
|
||||
<template #title>
|
||||
<span id="notice-tab-title">{{ $t('moderator.notice') }}</span>
|
||||
<b-tooltip target="notice-tab-title" triggers="hover">
|
||||
<BTooltip target="notice-tab-title" triggers="hover">
|
||||
{{ $t('moderator.notice-tooltip') }}
|
||||
</b-tooltip>
|
||||
</BTooltip>
|
||||
</template>
|
||||
<b-form-textarea
|
||||
<BFormTextarea
|
||||
id="textarea"
|
||||
v-model="form.text"
|
||||
:placeholder="$t('moderator.notice')"
|
||||
rows="3"
|
||||
></b-form-textarea>
|
||||
</b-tab>
|
||||
<b-tab>
|
||||
/>
|
||||
</BTab>
|
||||
<BTab>
|
||||
<template #title>
|
||||
<span id="memo-tab-title">{{ $t('moderator.memo') }}</span>
|
||||
<b-tooltip target="memo-tab-title" triggers="hover">
|
||||
<BTooltip target="memo-tab-title" triggers="hover">
|
||||
{{ $t('moderator.memo-tooltip') }}
|
||||
</b-tooltip>
|
||||
</BTooltip>
|
||||
</template>
|
||||
<b-form-textarea
|
||||
<BFormTextarea
|
||||
id="textarea"
|
||||
v-model="form.memo"
|
||||
:placeholder="$t('contributionLink.memo')"
|
||||
rows="3"
|
||||
></b-form-textarea>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
<b-row class="mt-4 mb-6">
|
||||
<b-col>
|
||||
<b-button type="reset" variant="danger">{{ $t('form.cancel') }}</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-right">
|
||||
<b-button
|
||||
/>
|
||||
</BTab>
|
||||
</BTabs>
|
||||
<BRow class="mt-4 mb-6">
|
||||
<BCol>
|
||||
<BButton type="reset" variant="danger">{{ $t('form.cancel') }}</BButton>
|
||||
</BCol>
|
||||
<BCol class="text-end">
|
||||
<BButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:disabled="disabled"
|
||||
@click.prevent="onSubmit()"
|
||||
data-test="submit-dialog"
|
||||
@click.prevent="onSubmit()"
|
||||
>
|
||||
{{ $t('save') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-form>
|
||||
</BButton>
|
||||
</BCol>
|
||||
</BRow>
|
||||
</BForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useDateLocale } from '@/composables/useDateLocale'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Datepicker from 'vue3-datepicker'
|
||||
import TimePicker from '@/components/input/TimePicker'
|
||||
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
|
||||
import { adminUpdateContribution } from '@/graphql/adminUpdateContribution'
|
||||
import TimePicker from '@/components/input/TimePicker'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TimePicker,
|
||||
const props = defineProps({
|
||||
contributionId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
name: 'ContributionMessagesFormular',
|
||||
props: {
|
||||
contributionId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
contributionMemo: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hideResubmission: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
inputResubmissionDate: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
contributionMemo: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data() {
|
||||
const localInputResubmissionDate = this.inputResubmissionDate
|
||||
? new Date(this.inputResubmissionDate)
|
||||
: null
|
||||
hideResubmission: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
inputResubmissionDate: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
form: {
|
||||
text: '',
|
||||
memo: this.contributionMemo,
|
||||
},
|
||||
loading: false,
|
||||
resubmissionDate: localInputResubmissionDate,
|
||||
resubmissionTime: localInputResubmissionDate
|
||||
? localInputResubmissionDate.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '00:00',
|
||||
showResubmissionDate: localInputResubmissionDate !== null,
|
||||
tabindex: 0, // 0 = Chat, 1 = Notice, 2 = Memo
|
||||
messageType: {
|
||||
DIALOG: 'DIALOG',
|
||||
MODERATOR: 'MODERATOR',
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
combineResubmissionDateAndTime() {
|
||||
// getTimezoneOffset
|
||||
const formattedDate = new Date(this.resubmissionDate)
|
||||
const [hours, minutes] = this.resubmissionTime.split(':')
|
||||
formattedDate.setHours(parseInt(hours))
|
||||
formattedDate.setMinutes(parseInt(minutes))
|
||||
return formattedDate
|
||||
},
|
||||
utcResubmissionDateTime() {
|
||||
if (!this.resubmissionDate) return null
|
||||
const localResubmissionDateAndTime = this.combineResubmissionDateAndTime()
|
||||
return new Date(
|
||||
localResubmissionDateAndTime.getTime() +
|
||||
localResubmissionDateAndTime.getTimezoneOffset() * 60000,
|
||||
)
|
||||
},
|
||||
onSubmit() {
|
||||
this.loading = true
|
||||
let mutation
|
||||
let updateOnlyResubmissionAt = false
|
||||
const resubmissionAtDate = this.showResubmissionDate
|
||||
? this.combineResubmissionDateAndTime()
|
||||
: null
|
||||
const variables = {
|
||||
resubmissionAt: resubmissionAtDate ? resubmissionAtDate.toString() : null,
|
||||
}
|
||||
// update only resubmission date?
|
||||
if (this.form.text === '' && this.form.memo === this.contributionMemo) {
|
||||
mutation = adminUpdateContribution
|
||||
variables.id = this.contributionId
|
||||
updateOnlyResubmissionAt = true
|
||||
}
|
||||
// update tabindex 0 = dialog or 1 = moderator
|
||||
else if (this.tabindex !== 2) {
|
||||
mutation = adminCreateContributionMessage
|
||||
variables.message = this.form.text
|
||||
variables.messageType =
|
||||
this.tabindex === 0 ? this.messageType.DIALOG : this.messageType.MODERATOR
|
||||
variables.contributionId = this.contributionId
|
||||
// update contribution memo, tabindex 2
|
||||
const emit = defineEmits([
|
||||
'update-contribution',
|
||||
'update-contributions',
|
||||
'get-contribution',
|
||||
'update-status',
|
||||
'get-list-contribution-messages',
|
||||
])
|
||||
|
||||
const { t } = useI18n()
|
||||
const dateLocale = useDateLocale()
|
||||
const { toastError, toastSuccess } = useAppToast()
|
||||
const form = ref({
|
||||
text: '',
|
||||
memo: props.contributionMemo,
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
const localInputResubmissionDate = props.inputResubmissionDate
|
||||
? new Date(props.inputResubmissionDate)
|
||||
: null
|
||||
const resubmissionDate = ref(localInputResubmissionDate)
|
||||
const resubmissionTime = ref(
|
||||
localInputResubmissionDate
|
||||
? localInputResubmissionDate.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '00:00',
|
||||
)
|
||||
const showResubmissionDate = ref(localInputResubmissionDate !== null)
|
||||
const tabindex = ref(0) // 0 = Chat, 1 = Notice, 2 = Memo
|
||||
const messageType = {
|
||||
DIALOG: 'DIALOG',
|
||||
MODERATOR: 'MODERATOR',
|
||||
}
|
||||
|
||||
const isTextTabValid = computed(() => form.value.text !== '')
|
||||
|
||||
const isMemoTabValid = computed(() => form.value.memo.length >= 5)
|
||||
|
||||
const disabled = computed(
|
||||
() =>
|
||||
loading.value ||
|
||||
(!(showResubmissionDate.value && resubmissionDate.value) &&
|
||||
([0, 1].includes(tabindex.value)
|
||||
? !isTextTabValid.value
|
||||
: tabindex.value === 2
|
||||
? !isMemoTabValid.value
|
||||
: false)),
|
||||
)
|
||||
|
||||
const now = computed(() => new Date())
|
||||
|
||||
const { mutate: createContributionMessageMutation } = useMutation(adminCreateContributionMessage)
|
||||
const { mutate: updateContributionMutation } = useMutation(adminUpdateContribution)
|
||||
|
||||
const combineResubmissionDateAndTime = () => {
|
||||
const formattedDate = new Date(resubmissionDate.value)
|
||||
const [hours, minutes] = resubmissionTime.value.split(':')
|
||||
formattedDate.setHours(parseInt(hours))
|
||||
formattedDate.setMinutes(parseInt(minutes))
|
||||
return formattedDate
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
loading.value = true
|
||||
let mutation
|
||||
let updateOnlyResubmissionAt = false
|
||||
const resubmissionAtDate = showResubmissionDate.value ? combineResubmissionDateAndTime() : null
|
||||
const variables = {
|
||||
resubmissionAt: resubmissionAtDate ? resubmissionAtDate.toString() : null,
|
||||
}
|
||||
|
||||
if (form.value.text === '' && form.value.memo === props.contributionMemo) {
|
||||
mutation = updateContributionMutation
|
||||
variables.id = props.contributionId
|
||||
updateOnlyResubmissionAt = true
|
||||
} else if (tabindex.value !== 2) {
|
||||
mutation = createContributionMessageMutation
|
||||
variables.message = form.value.text
|
||||
variables.messageType = tabindex.value === 0 ? messageType.DIALOG : messageType.MODERATOR
|
||||
variables.contributionId = props.contributionId
|
||||
} else {
|
||||
mutation = updateContributionMutation
|
||||
variables.memo = form.value.memo
|
||||
variables.id = props.contributionId
|
||||
}
|
||||
|
||||
if (showResubmissionDate.value && resubmissionAtDate < new Date()) {
|
||||
toastError(t('contributionMessagesForm.resubmissionDateInPast'))
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
mutation({ ...variables })
|
||||
.then(() => {
|
||||
if (
|
||||
(props.hideResubmission && showResubmissionDate.value && resubmissionAtDate > new Date()) ||
|
||||
tabindex.value === 2
|
||||
) {
|
||||
emit('update-contributions')
|
||||
} else {
|
||||
mutation = adminUpdateContribution
|
||||
variables.memo = this.form.memo
|
||||
variables.id = this.contributionId
|
||||
emit('get-list-contribution-messages', props.contributionId)
|
||||
if (!updateOnlyResubmissionAt) {
|
||||
emit('update-status', props.contributionId)
|
||||
}
|
||||
}
|
||||
if (this.showResubmissionDate && resubmissionAtDate < new Date()) {
|
||||
this.toastError(this.$t('contributionMessagesForm.resubmissionDateInPast'))
|
||||
this.loading = false
|
||||
return
|
||||
toastSuccess(t('message.request'))
|
||||
form.value = {
|
||||
text: '',
|
||||
memo: props.contributionMemo,
|
||||
}
|
||||
this.$apollo
|
||||
.mutate({ mutation, variables })
|
||||
.then((result) => {
|
||||
if (
|
||||
(this.hideResubmission &&
|
||||
this.showResubmissionDate &&
|
||||
resubmissionAtDate > new Date()) ||
|
||||
this.tabindex === 2
|
||||
) {
|
||||
this.$emit('update-contributions')
|
||||
} else {
|
||||
this.$emit('get-list-contribution-messages', this.contributionId)
|
||||
// update status increase message count and update chat symbol
|
||||
// if (updateOnlyResubmissionAt === true) no message was created
|
||||
if (!updateOnlyResubmissionAt) {
|
||||
this.$emit('update-status', this.contributionId)
|
||||
}
|
||||
}
|
||||
this.toastSuccess(this.$t('message.request'))
|
||||
this.loading = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
onReset(event) {
|
||||
this.form.text = ''
|
||||
this.form.memo = this.contributionMemo
|
||||
this.showResubmissionDate = false
|
||||
this.resubmissionDate = this.inputResubmissionDate
|
||||
this.resubmissionTime = this.inputResubmissionDate
|
||||
? new Date(this.inputResubmissionDate).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '00:00'
|
||||
this.showResubmissionDate =
|
||||
this.inputResubmissionDate !== undefined && this.inputResubmissionDate !== null
|
||||
},
|
||||
enableMemo() {
|
||||
this.chatOrMemo = 1
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
disabled() {
|
||||
return (
|
||||
(this.chatOrMemo === 0 && this.form.text === '') ||
|
||||
this.loading ||
|
||||
(this.chatOrMemo === 1 && this.form.memo.length < 5) ||
|
||||
(this.showResubmissionDate && !this.resubmissionDate)
|
||||
)
|
||||
},
|
||||
moderatorDisabled() {
|
||||
return this.form.text === '' || this.loading || this.chatOrMemo === 1
|
||||
},
|
||||
now() {
|
||||
return new Date()
|
||||
},
|
||||
},
|
||||
loading.value = false
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.message)
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.value.text = ''
|
||||
form.value.memo = props.contributionMemo
|
||||
showResubmissionDate.value = false
|
||||
resubmissionDate.value = props.inputResubmissionDate
|
||||
resubmissionTime.value = props.inputResubmissionDate
|
||||
? new Date(props.inputResubmissionDate).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '00:00'
|
||||
showResubmissionDate.value =
|
||||
props.inputResubmissionDate !== undefined && props.inputResubmissionDate !== null
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,170 +1,185 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContributionMessagesList from './ContributionMessagesList'
|
||||
import VueApollo from 'vue-apollo'
|
||||
import { createMockClient } from 'mock-apollo-client'
|
||||
import { adminListContributionMessages } from '../../graphql/adminListContributionMessages.js'
|
||||
import { toastErrorSpy } from '../../../test/testSetup'
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import ContributionMessagesList from './ContributionMessagesList.vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import { BContainer } from 'bootstrap-vue-next'
|
||||
|
||||
const mockClient = createMockClient()
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: mockClient,
|
||||
})
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
localVue.use(VueApollo)
|
||||
|
||||
const defaultData = () => {
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
adminListContributionMessages: {
|
||||
count: 4,
|
||||
messages: [
|
||||
{
|
||||
id: 43,
|
||||
message: 'A DIALOG message',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 1,
|
||||
isModerator: true,
|
||||
},
|
||||
{
|
||||
id: 44,
|
||||
message: 'Another DIALOG message',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Bibi',
|
||||
userLastName: 'Bloxberg',
|
||||
userId: 2,
|
||||
isModerator: false,
|
||||
},
|
||||
{
|
||||
id: 45,
|
||||
message: `DATE
|
||||
---
|
||||
A HISTORY message
|
||||
---
|
||||
AMOUNT`,
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: null,
|
||||
type: 'HISTORY',
|
||||
userFirstName: 'Bibi',
|
||||
userLastName: 'Bloxberg',
|
||||
userId: 2,
|
||||
isModerator: false,
|
||||
},
|
||||
{
|
||||
id: 46,
|
||||
message: 'A MODERATOR message',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: null,
|
||||
type: 'MODERATOR',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 1,
|
||||
isModerator: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
...actual,
|
||||
ref: vi.fn(actual.ref),
|
||||
}
|
||||
})
|
||||
vi.mock('@vue/apollo-composable')
|
||||
vi.mock('@/composables/useToast')
|
||||
|
||||
const defaultData = {
|
||||
adminListContributionMessages: {
|
||||
count: 4,
|
||||
messages: [
|
||||
{
|
||||
id: 43,
|
||||
message: 'A DIALOG message',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 1,
|
||||
isModerator: true,
|
||||
},
|
||||
{
|
||||
id: 44,
|
||||
message: 'Another DIALOG message',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Bibi',
|
||||
userLastName: 'Bloxberg',
|
||||
userId: 2,
|
||||
isModerator: false,
|
||||
},
|
||||
{
|
||||
id: 45,
|
||||
message: `DATE\n---\nA HISTORY message\n---\nAMOUNT`,
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: null,
|
||||
type: 'HISTORY',
|
||||
userFirstName: 'Bibi',
|
||||
userLastName: 'Bloxberg',
|
||||
userId: 2,
|
||||
isModerator: false,
|
||||
},
|
||||
{
|
||||
id: 46,
|
||||
message: 'A MODERATOR message',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: null,
|
||||
type: 'MODERATOR',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 1,
|
||||
isModerator: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const defaultUser = {
|
||||
firstName: 'Peter',
|
||||
lastName: 'Lustig',
|
||||
humhubUsername: 'peter.lustig',
|
||||
createdAt: new Date().toString(),
|
||||
emailContact: {
|
||||
email: 'peter.lustig@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
describe('ContributionMessagesList', () => {
|
||||
let wrapper
|
||||
let mockMessages
|
||||
const mockRefetch = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
const adminListContributionMessagessMock = jest.fn()
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockClient.setRequestHandler(
|
||||
adminListContributionMessages,
|
||||
adminListContributionMessagessMock
|
||||
.mockRejectedValueOnce({ message: 'Auaa!' })
|
||||
.mockResolvedValue({ data: defaultData() }),
|
||||
)
|
||||
mockMessages = ref([])
|
||||
ref.mockReturnValueOnce(mockMessages)
|
||||
|
||||
const propsData = {
|
||||
contributionId: 42,
|
||||
contributionMemo: 'test memo',
|
||||
contributionUserId: 108,
|
||||
contributionStatus: 'PENDING',
|
||||
hideResubmission: true,
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$n: jest.fn((n) => n),
|
||||
$i18n: {
|
||||
locale: 'en',
|
||||
},
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ContributionMessagesList, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
apolloProvider,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = Wrapper()
|
||||
useQuery.mockReturnValue({
|
||||
onResult: vi.fn((callback) => callback({ result: defaultData })),
|
||||
onError: vi.fn(),
|
||||
result: { value: defaultData },
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
describe('server response for admin list contribution messages is error', () => {
|
||||
it('toast an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Auaa!')
|
||||
})
|
||||
useAppToast.mockReturnValue({
|
||||
toastError: mockToastError,
|
||||
})
|
||||
|
||||
describe('server response is succes', () => {
|
||||
it('has a DIV .contribution-messages-list', () => {
|
||||
expect(wrapper.find('div.contribution-messages-list').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has 4 messages', () => {
|
||||
expect(wrapper.findAll('div.contribution-messages-list-item')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('has a Component ContributionMessagesFormular', () => {
|
||||
expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true)
|
||||
})
|
||||
wrapper = mount(ContributionMessagesList, {
|
||||
props: {
|
||||
contribution: {
|
||||
id: 42,
|
||||
memo: 'test memo',
|
||||
userId: 108,
|
||||
status: 'PENDING',
|
||||
user: defaultUser,
|
||||
},
|
||||
hideResubmission: true,
|
||||
},
|
||||
global: {
|
||||
components: {
|
||||
BContainer,
|
||||
},
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$d: (date) => date,
|
||||
$n: (number) => number,
|
||||
},
|
||||
stubs: {
|
||||
'contribution-messages-list-item': true,
|
||||
'contribution-messages-formular': true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('call updateStatus', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.updateStatus(4)
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('emits update-status', () => {
|
||||
expect(wrapper.vm.$root.$emit('update-status', 4)).toBeTruthy()
|
||||
})
|
||||
afterEach(() => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.contribution-messages-list').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the correct number of messages', async () => {
|
||||
wrapper.vm.messages = defaultData.adminListContributionMessages.messages
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findAll('contribution-messages-list-item-stub')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('renders the ContributionMessagesFormular when status is PENDING', () => {
|
||||
expect(wrapper.find('contribution-messages-formular-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render the ContributionMessagesFormular when status is not PENDING or IN_PROGRESS', async () => {
|
||||
await wrapper.setProps({
|
||||
contribution: {
|
||||
status: 'COMPLETED',
|
||||
user: defaultUser,
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('contribution-messages-formular-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('test reload-contribution', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.reloadContribution(3)
|
||||
})
|
||||
it('updates messages when result changes', async () => {
|
||||
const newMessages = [{ id: 1, message: 'New message' }]
|
||||
mockMessages.value = newMessages
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findAll('contribution-messages-list-item-stub')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits reload-contribution', () => {
|
||||
expect(wrapper.emitted('reload-contribution')).toBeTruthy()
|
||||
expect(wrapper.emitted('reload-contribution')[0]).toEqual([3])
|
||||
})
|
||||
})
|
||||
it('emits update-status event', async () => {
|
||||
await wrapper.vm.updateStatus(4)
|
||||
expect(wrapper.emitted('update-status')).toBeTruthy()
|
||||
expect(wrapper.emitted('update-status')[0]).toEqual([4])
|
||||
})
|
||||
|
||||
describe('test update-contributions', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.updateContributions()
|
||||
})
|
||||
it('emits reload-contribution event', async () => {
|
||||
await wrapper.vm.reloadContribution(3)
|
||||
expect(wrapper.emitted('reload-contribution')).toBeTruthy()
|
||||
expect(wrapper.emitted('reload-contribution')[0]).toEqual([3])
|
||||
})
|
||||
|
||||
it('emits update-contributions', () => {
|
||||
expect(wrapper.emitted('update-contributions')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
it('emits update-contributions event', async () => {
|
||||
await wrapper.vm.updateContributions()
|
||||
expect(wrapper.emitted('update-contributions')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,20 +1,53 @@
|
||||
<template>
|
||||
<div class="contribution-messages-list">
|
||||
<b-container>
|
||||
<div v-for="message in messages" v-bind:key="message.id">
|
||||
<BListGroup>
|
||||
<BListGroupItem>
|
||||
<routerLink :to="searchLink" :title="$t('goTo.userSearch')">
|
||||
{{ contribution.user.firstName }} {{ contribution.user.lastName }}
|
||||
</routerLink>
|
||||
|
||||
<a :href="mailtoLink">{{ email }}</a>
|
||||
<IBiFilter id="filter-by-email" class="ms-1 pointer" @click="searchForEmail" />
|
||||
<BTooltip target="filter-by-email" triggers="hover">
|
||||
{{ $t('filter.byEmail') }}
|
||||
</BTooltip>
|
||||
|
||||
{{ contribution.user.humhubUsername }}
|
||||
|
||||
<span>
|
||||
<a
|
||||
v-if="humhubProfileLink"
|
||||
id="humhub-username"
|
||||
:href="humhubProfileLink"
|
||||
target="_blank"
|
||||
>
|
||||
<i-arcticons-circles class="svg-icon" />
|
||||
</a>
|
||||
<BTooltip target="humhub-username" triggers="hover">
|
||||
{{ $t('goTo.humhubProfile') }}
|
||||
</BTooltip>
|
||||
</span>
|
||||
</BListGroupItem>
|
||||
<BListGroupItem>
|
||||
{{ $t('registered') }}: {{ new Date(contribution.user.createdAt).toLocaleDateString() }},
|
||||
{{ $t('createdAt') }}: {{ new Date(contribution.createdAt).toLocaleDateString() }}
|
||||
</BListGroupItem>
|
||||
</BListGroup>
|
||||
<BContainer>
|
||||
<div v-for="message in messages" :key="message.id">
|
||||
<contribution-messages-list-item
|
||||
:message="message"
|
||||
:contributionUserId="contributionUserId"
|
||||
:contribution-user-id="contribution.userId"
|
||||
/>
|
||||
</div>
|
||||
</b-container>
|
||||
<div v-if="contributionStatus === 'PENDING' || contributionStatus === 'IN_PROGRESS'">
|
||||
</BContainer>
|
||||
<div v-if="contribution.status === 'PENDING' || contribution.status === 'IN_PROGRESS'">
|
||||
<contribution-messages-formular
|
||||
:contributionId="contributionId"
|
||||
:contributionMemo="contributionMemo"
|
||||
:hideResubmission="hideResubmission"
|
||||
:inputResubmissionDate="resubmissionAt"
|
||||
@get-list-contribution-messages="$apollo.queries.Messages.refetch()"
|
||||
:contribution-id="contribution.id"
|
||||
:contribution-memo="contribution.memo"
|
||||
:hide-resubmission="hideResubmission"
|
||||
:input-resubmission-date="resubmissionAt"
|
||||
@get-list-contribution-messages="refetch"
|
||||
@update-status="updateStatus"
|
||||
@reload-contribution="reloadContribution"
|
||||
@update-contributions="updateContributions"
|
||||
@ -22,78 +55,91 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import ContributionMessagesListItem from './slots/ContributionMessagesListItem'
|
||||
import ContributionMessagesFormular from '../ContributionMessages/ContributionMessagesFormular'
|
||||
import { adminListContributionMessages } from '../../graphql/adminListContributionMessages.js'
|
||||
|
||||
export default {
|
||||
name: 'ContributionMessagesList',
|
||||
components: {
|
||||
ContributionMessagesListItem,
|
||||
ContributionMessagesFormular,
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { adminListContributionMessages } from '../../graphql/adminListContributionMessages.js'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import { BListGroupItem } from 'bootstrap-vue-next'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
const props = defineProps({
|
||||
contribution: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
contributionId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
contributionMemo: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
contributionStatus: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
contributionUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hideResubmission: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
resubmissionAt: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
hideResubmission: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
}
|
||||
resubmissionAt: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
apollo: {
|
||||
Messages: {
|
||||
query() {
|
||||
return adminListContributionMessages
|
||||
},
|
||||
variables() {
|
||||
return {
|
||||
contributionId: this.contributionId,
|
||||
}
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
update({ adminListContributionMessages }) {
|
||||
this.messages = adminListContributionMessages.messages
|
||||
},
|
||||
error({ message }) {
|
||||
this.toastError(message)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update-status',
|
||||
'reload-contribution',
|
||||
'update-contributions',
|
||||
'search-for-email',
|
||||
])
|
||||
const email = computed(() => {
|
||||
return props.contribution.user.emailContact.email
|
||||
})
|
||||
const { toastError } = useAppToast()
|
||||
const mailtoLink = computed(() => {
|
||||
return `mailto:${email.value}`
|
||||
})
|
||||
const searchLink = computed(() => {
|
||||
return `/user?search=${email.value}`
|
||||
})
|
||||
const humhubProfileLink = computed(() => {
|
||||
if (CONFIG.HUMHUB_ACTIVE !== true) {
|
||||
return undefined
|
||||
}
|
||||
let url = CONFIG.HUMHUB_API_URL
|
||||
if (url.endsWith('/')) {
|
||||
url = url.slice(0, -1)
|
||||
}
|
||||
return `${url}/u/${props.contribution.humhubUsername}`
|
||||
})
|
||||
|
||||
const messages = ref([])
|
||||
|
||||
const { onResult, onError, result, refetch } = useQuery(
|
||||
adminListContributionMessages,
|
||||
{
|
||||
contributionId: props.contribution.id,
|
||||
},
|
||||
methods: {
|
||||
updateStatus(id) {
|
||||
this.$emit('update-status', id)
|
||||
},
|
||||
reloadContribution(id) {
|
||||
this.$emit('reload-contribution', id)
|
||||
},
|
||||
updateContributions() {
|
||||
this.$emit('update-contributions')
|
||||
},
|
||||
{
|
||||
fetchPolicy: 'no-cache',
|
||||
},
|
||||
)
|
||||
|
||||
onError((error) => {
|
||||
toastError(error.message)
|
||||
})
|
||||
|
||||
onResult(() => {
|
||||
messages.value = result.value.adminListContributionMessages.messages
|
||||
})
|
||||
|
||||
const updateStatus = (id) => {
|
||||
emit('update-status', id)
|
||||
}
|
||||
|
||||
const reloadContribution = (id) => {
|
||||
emit('reload-contribution', id)
|
||||
}
|
||||
|
||||
const updateContributions = () => {
|
||||
emit('update-contributions')
|
||||
}
|
||||
|
||||
const searchForEmail = () => {
|
||||
emit('search-for-email', email.value)
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
@ -26,9 +26,9 @@ export default {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
messageType: {
|
||||
type: String,
|
||||
reuired: true,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
@ -36,7 +36,7 @@ export default {
|
||||
let string = this.message
|
||||
const linkified = []
|
||||
let amount
|
||||
if (this.type === 'HISTORY') {
|
||||
if (this.messageType === 'HISTORY') {
|
||||
const split = string.split(/\n\s*---\n\s*/)
|
||||
string = split[1]
|
||||
linkified.push({ type: 'date', text: split[0].trim() })
|
||||
|
||||
@ -1,260 +1,155 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContributionMessagesListItem from './ContributionMessagesListItem'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
vi.mock('@/components/ContributionMessages/ParseMessage', () => ({
|
||||
default: {
|
||||
name: 'ParseMessage',
|
||||
template: '<div>{{ message }}</div>',
|
||||
props: ['message'],
|
||||
},
|
||||
}))
|
||||
|
||||
const dateMock = jest.fn((d) => d)
|
||||
const numberMock = jest.fn((n) => n)
|
||||
|
||||
describe('ContributionMessagesListItem', () => {
|
||||
let wrapper
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: dateMock,
|
||||
$n: numberMock,
|
||||
$store: {
|
||||
state: {
|
||||
moderator: {
|
||||
firstName: 'Peter',
|
||||
lastName: 'Lustig',
|
||||
const createWrapper = (propsData) => {
|
||||
return mount(ContributionMessagesListItem, {
|
||||
props: propsData,
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$d: vi.fn((date) => date.toISOString()),
|
||||
$n: vi.fn((n) => n.toString()),
|
||||
$store: {
|
||||
state: {
|
||||
moderator: {
|
||||
firstName: 'Peter',
|
||||
lastName: 'Lustig',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('if message author has moderator role', () => {
|
||||
const propsData = {
|
||||
contributionId: 42,
|
||||
contributionUserId: 108,
|
||||
state: 'PENDING',
|
||||
message: {
|
||||
id: 111,
|
||||
message: 'Lorem ipsum?',
|
||||
createdAt: '2022-08-29T12:23:27.000Z',
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 107,
|
||||
isModerator: true,
|
||||
__typename: 'ContributionMessage',
|
||||
stubs: {
|
||||
BAvatar: true,
|
||||
VariantIcon: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const ModeratorItemWrapper = () => {
|
||||
return mount(ContributionMessagesListItem, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
})
|
||||
}
|
||||
describe('ContributionMessagesListItem', () => {
|
||||
describe('if message author has moderator role', () => {
|
||||
let wrapper
|
||||
|
||||
describe('mount', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = ModeratorItemWrapper()
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper({
|
||||
contributionUserId: 108,
|
||||
message: {
|
||||
id: 111,
|
||||
message: 'Lorem ipsum?',
|
||||
createdAt: '2022-08-29T12:23:27.000Z',
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 107,
|
||||
isModerator: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('has a DIV .text-right.is-moderator', () => {
|
||||
expect(wrapper.find('div.text-right.is-moderator').exists()).toBe(true)
|
||||
})
|
||||
it('has a DIV .text-end.is-moderator', () => {
|
||||
expect(wrapper.find('div.text-end.is-moderator').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has the complete user name', () => {
|
||||
expect(wrapper.find('[data-test="moderator-name"]').text()).toBe('Peter Lustig')
|
||||
})
|
||||
it('has the complete user name', () => {
|
||||
expect(wrapper.find('[data-test="moderator-name"]').text()).toBe('Peter Lustig')
|
||||
})
|
||||
|
||||
it('has the message creation date', () => {
|
||||
expect(wrapper.find('[data-test="moderator-date"]').text()).toMatch(
|
||||
'Mon Aug 29 2022 12:23:27 GMT+0000',
|
||||
)
|
||||
})
|
||||
it('has the message creation date', () => {
|
||||
expect(wrapper.find('[data-test="moderator-date"]').text()).toBe('2022-08-29T12:23:27.000Z')
|
||||
})
|
||||
|
||||
it('has the moderator label', () => {
|
||||
expect(wrapper.find('[data-test="moderator-label"]').text()).toBe('moderator.moderator')
|
||||
})
|
||||
it('has the moderator label', () => {
|
||||
expect(wrapper.find('[data-test="moderator-label"]').text()).toBe('moderator.moderator')
|
||||
})
|
||||
|
||||
it('has the message', () => {
|
||||
expect(wrapper.find('[data-test="moderator-message"]').text()).toBe('Lorem ipsum?')
|
||||
})
|
||||
it('has the message', () => {
|
||||
expect(wrapper.find('[data-test="moderator-message"]').text()).toBe('Lorem ipsum?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('if message author does not have moderator role', () => {
|
||||
const propsData = {
|
||||
contributionId: 42,
|
||||
contributionUserId: 108,
|
||||
state: 'PENDING',
|
||||
message: {
|
||||
id: 113,
|
||||
message: 'Asda sdad ad asdasd, das Ass das Das. ',
|
||||
createdAt: '2022-08-29T12:25:34.000Z',
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Bibi',
|
||||
userLastName: 'Bloxberg',
|
||||
userId: 108,
|
||||
__typename: 'ContributionMessage',
|
||||
},
|
||||
}
|
||||
let wrapper
|
||||
|
||||
const ItemWrapper = () => {
|
||||
return mount(ContributionMessagesListItem, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = ItemWrapper()
|
||||
})
|
||||
|
||||
it('has a DIV .text-left.is-not-moderator', () => {
|
||||
expect(wrapper.find('div.text-left.is-user').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has the complete user name', () => {
|
||||
expect(wrapper.find('[data-test="user-name"]').text()).toBe('Bibi Bloxberg')
|
||||
})
|
||||
|
||||
it('has the message creation date', () => {
|
||||
expect(wrapper.find('[data-test="user-date"]').text()).toMatch(
|
||||
'Mon Aug 29 2022 12:25:34 GMT+0000',
|
||||
)
|
||||
})
|
||||
|
||||
it('has the message', () => {
|
||||
expect(wrapper.find('[data-test="user-message"]').text()).toBe(
|
||||
'Asda sdad ad asdasd, das Ass das Das.',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('links in contribtion message', () => {
|
||||
const propsData = {
|
||||
contributionUserId: 108,
|
||||
message: {
|
||||
id: 111,
|
||||
message: 'Lorem ipsum?',
|
||||
createdAt: '2022-08-29T12:23:27.000Z',
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 107,
|
||||
__typename: 'ContributionMessage',
|
||||
},
|
||||
}
|
||||
|
||||
const ModeratorItemWrapper = () => {
|
||||
return mount(ContributionMessagesListItem, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
})
|
||||
}
|
||||
|
||||
let messageField
|
||||
|
||||
describe('message of only one link', () => {
|
||||
beforeEach(() => {
|
||||
propsData.message.message = 'https://gradido.net/de/'
|
||||
wrapper = ModeratorItemWrapper()
|
||||
messageField = wrapper.find('[data-test="moderator-message"]')
|
||||
})
|
||||
|
||||
it('contains the link as text', () => {
|
||||
expect(messageField.text()).toBe('https://gradido.net/de/')
|
||||
})
|
||||
|
||||
it('contains a link to the given address', () => {
|
||||
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper({
|
||||
contributionUserId: 108,
|
||||
message: {
|
||||
id: 113,
|
||||
message: 'Asda sdad ad asdasd, das Ass das Das.',
|
||||
createdAt: '2022-08-29T12:25:34.000Z',
|
||||
updatedAt: null,
|
||||
type: 'DIALOG',
|
||||
userFirstName: 'Bibi',
|
||||
userLastName: 'Bloxberg',
|
||||
userId: 108,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('message with text and two links', () => {
|
||||
beforeEach(() => {
|
||||
propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/
|
||||
and here is the link to the repository: https://github.com/gradido/gradido`
|
||||
wrapper = ModeratorItemWrapper()
|
||||
messageField = wrapper.find('[data-test="moderator-message"]')
|
||||
})
|
||||
it('has a DIV .text-start.is-user', () => {
|
||||
expect(wrapper.find('div.text-start.is-user').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('contains the whole text', () => {
|
||||
expect(messageField.text())
|
||||
.toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/
|
||||
and here is the link to the repository: https://github.com/gradido/gradido`)
|
||||
})
|
||||
it('has the complete user name', () => {
|
||||
expect(wrapper.find('[data-test="user-name"]').text()).toBe('Bibi Bloxberg')
|
||||
})
|
||||
|
||||
it('contains the two links', () => {
|
||||
expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/')
|
||||
expect(messageField.findAll('a').at(1).attributes('href')).toBe(
|
||||
'https://github.com/gradido/gradido',
|
||||
)
|
||||
})
|
||||
it('has the message creation date', () => {
|
||||
expect(wrapper.find('[data-test="user-date"]').text()).toBe('2022-08-29T12:25:34.000Z')
|
||||
})
|
||||
|
||||
it('has the message', () => {
|
||||
expect(wrapper.find('[data-test="user-message"]').text()).toBe(
|
||||
'Asda sdad ad asdasd, das Ass das Das.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contribution message type HISTORY', () => {
|
||||
const propsData = {
|
||||
contributionUserId: 108,
|
||||
message: {
|
||||
id: 111,
|
||||
message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time)
|
||||
let wrapper
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper({
|
||||
contributionUserId: 108,
|
||||
message: {
|
||||
id: 111,
|
||||
message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time)
|
||||
---
|
||||
This message also contains a link: https://gradido.net/de/
|
||||
---
|
||||
350.00`,
|
||||
createdAt: '2022-08-29T12:23:27.000Z',
|
||||
updatedAt: null,
|
||||
type: 'HISTORY',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 107,
|
||||
__typename: 'ContributionMessage',
|
||||
},
|
||||
}
|
||||
|
||||
const itemWrapper = () => {
|
||||
return mount(ContributionMessagesListItem, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
createdAt: '2022-08-29T12:23:27.000Z',
|
||||
updatedAt: null,
|
||||
type: 'HISTORY',
|
||||
userFirstName: 'Peter',
|
||||
userLastName: 'Lustig',
|
||||
userId: 107,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let messageField
|
||||
it('renders the history label', () => {
|
||||
expect(wrapper.text()).toContain('moderator.history')
|
||||
})
|
||||
|
||||
describe('render HISTORY message', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = itemWrapper()
|
||||
messageField = wrapper
|
||||
})
|
||||
|
||||
it('renders the date', () => {
|
||||
expect(dateMock).toBeCalledWith(
|
||||
new Date('Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time'),
|
||||
'short',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the amount', () => {
|
||||
expect(numberMock).toBeCalledWith(350, 'decimal')
|
||||
expect(messageField.text()).toContain('350 GDD')
|
||||
})
|
||||
|
||||
it('contains the link as text', () => {
|
||||
expect(messageField.text()).toContain(
|
||||
'This message also contains a link: https://gradido.net/de/',
|
||||
)
|
||||
})
|
||||
|
||||
it('contains a link to the given address', () => {
|
||||
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
|
||||
})
|
||||
it('renders the message', () => {
|
||||
expect(wrapper.find('[data-test="moderator-message"]').text()).toContain(
|
||||
'Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time)',
|
||||
)
|
||||
expect(wrapper.find('[data-test="moderator-message"]').text()).toContain(
|
||||
'This message also contains a link: https://gradido.net/de/',
|
||||
)
|
||||
expect(wrapper.find('[data-test="moderator-message"]').text()).toContain('350.00')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<div class="contribution-messages-list-item clearfix">
|
||||
<div v-if="isModeratorMessage" class="text-right p-2 rounded-sm mb-3" :class="boxClass">
|
||||
<small class="ml-4" data-test="moderator-label">
|
||||
<div v-if="isModeratorMessage" class="text-end p-2 rounded-sm mb-3" :class="boxClass">
|
||||
<small class="ms-4" data-test="moderator-label">
|
||||
{{ $t('moderator.moderator') }}
|
||||
</small>
|
||||
<small class="ml-2" data-test="moderator-date">
|
||||
<small class="ms-2" data-test="moderator-date">
|
||||
{{ $d(new Date(message.createdAt), 'short') }}
|
||||
</small>
|
||||
<span class="ml-2 mr-2" data-test="moderator-name">
|
||||
<span class="ms-2 me-2 no-select" data-test="moderator-name">
|
||||
{{ message.userFirstName }} {{ message.userLastName }}
|
||||
</span>
|
||||
<b-avatar square variant="warning"></b-avatar>
|
||||
<BAvatar square variant="warning">
|
||||
<variant-icon icon="person-fill" variant="black" />
|
||||
</BAvatar>
|
||||
<small v-if="isHistory">
|
||||
<hr />
|
||||
{{ $t('moderator.history') }}
|
||||
@ -22,12 +24,14 @@
|
||||
{{ $t('moderator.request') }}
|
||||
</small>
|
||||
</div>
|
||||
<div v-else class="text-left p-2 rounded-sm mb-3" :class="boxClass">
|
||||
<b-avatar variant="info"></b-avatar>
|
||||
<span class="ml-2 mr-2" data-test="user-name">
|
||||
<div v-else class="text-start p-2 rounded-sm mb-3" :class="boxClass">
|
||||
<BAvatar variant="info">
|
||||
<variant-icon icon="person-fill" variant="white" />
|
||||
</BAvatar>
|
||||
<span class="ms-2 me-2 no-select" data-test="user-name">
|
||||
{{ message.userFirstName }} {{ message.userLastName }}
|
||||
</span>
|
||||
<small class="ml-2" data-test="user-date">
|
||||
<small class="ms-2" data-test="user-date">
|
||||
{{ $d(new Date(message.createdAt), 'short') }}
|
||||
</small>
|
||||
<small v-if="isHistory">
|
||||
@ -82,20 +86,25 @@ export default {
|
||||
float: right;
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.is-moderator-message {
|
||||
background-color: rgb(228, 237, 245);
|
||||
background-color: rgb(228 237 245);
|
||||
}
|
||||
|
||||
.is-moderator-hidden-message {
|
||||
background-color: rgb(217, 161, 228);
|
||||
background-color: rgb(217 161 228);
|
||||
}
|
||||
|
||||
.is-user {
|
||||
clear: both;
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.is-user-message {
|
||||
background-color: rgb(236, 235, 213);
|
||||
background-color: rgb(236 235 213);
|
||||
}
|
||||
|
||||
.is-user-history-message {
|
||||
background-color: rgb(235, 226, 57);
|
||||
background-color: rgb(235 226 57);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,364 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CreationFormular from './CreationFormular'
|
||||
import { adminCreateContribution } from '../graphql/adminCreateContribution'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
import VueApollo from 'vue-apollo'
|
||||
import { createMockClient } from 'mock-apollo-client'
|
||||
import { adminOpenCreations } from '../graphql/adminOpenCreations'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import CreationFormular from './CreationFormular.vue'
|
||||
import { BFormRadioGroup } from 'bootstrap-vue-next'
|
||||
|
||||
const mockClient = createMockClient()
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: mockClient,
|
||||
})
|
||||
|
||||
const localVue = global.localVue
|
||||
localVue.use(VueApollo)
|
||||
|
||||
const stateCommitMock = jest.fn()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t, options) => (options ? [t, options] : t)),
|
||||
$d: jest.fn((d) => {
|
||||
const date = new Date(d)
|
||||
return date.toISOString().split('T')[0]
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key,
|
||||
}),
|
||||
$store: {
|
||||
commit: stateCommitMock,
|
||||
},
|
||||
}
|
||||
}))
|
||||
|
||||
const propsData = {
|
||||
type: '',
|
||||
creation: [],
|
||||
}
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useAppToast: () => ({
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const now = new Date()
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
useQuery: () => ({
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const getCreationDate = (sub) => {
|
||||
const date = sub === 0 ? now : new Date(now.getFullYear(), now.getMonth() - sub, 1, 0)
|
||||
return date.toISOString().split('T')[0]
|
||||
vi.mock('vuex', () => ({
|
||||
useStore: () => ({
|
||||
commit: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useCreationMonths', () => ({
|
||||
default: () => ({
|
||||
creationDateObjects: ref([
|
||||
{ short: 'Jan', year: '2024', date: '2024-01-01' },
|
||||
{ short: 'Feb', year: '2024', date: '2024-02-01' },
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockChildComponents = {
|
||||
BForm: { template: '<div><slot></slot></div>' },
|
||||
BFormRadioGroup,
|
||||
BInputGroup: { template: '<div><slot></slot></div>' },
|
||||
BFormInput: { template: '<input />', props: ['modelValue'] },
|
||||
BFormTextarea: { template: '<textarea></textarea>', props: ['modelValue'] },
|
||||
BButton: { template: '<button type="button"></button>' },
|
||||
}
|
||||
|
||||
describe('CreationFormular', () => {
|
||||
let wrapper
|
||||
|
||||
const adminOpenCreationsMock = jest.fn()
|
||||
const adminCreateContributionMock = jest.fn()
|
||||
mockClient.setRequestHandler(
|
||||
adminOpenCreations,
|
||||
adminOpenCreationsMock.mockResolvedValue({
|
||||
data: {
|
||||
adminOpenCreations: [
|
||||
{
|
||||
month: new Date(now.getFullYear(), now.getMonth() - 2).getMonth(),
|
||||
year: new Date(now.getFullYear(), now.getMonth() - 2).getFullYear(),
|
||||
amount: '200',
|
||||
},
|
||||
{
|
||||
month: new Date(now.getFullYear(), now.getMonth() - 1).getMonth(),
|
||||
year: new Date(now.getFullYear(), now.getMonth() - 1).getFullYear(),
|
||||
amount: '400',
|
||||
},
|
||||
{
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
amount: '600',
|
||||
},
|
||||
],
|
||||
beforeEach(() => {
|
||||
wrapper = mount(CreationFormular, {
|
||||
global: {
|
||||
stubs: mockChildComponents,
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
mockClient.setRequestHandler(
|
||||
adminCreateContribution,
|
||||
adminCreateContributionMock.mockResolvedValue({
|
||||
data: {
|
||||
adminCreateContribution: [0, 0, 0],
|
||||
props: {
|
||||
pagetype: '',
|
||||
item: {},
|
||||
items: [],
|
||||
creationUserData: {},
|
||||
creation: [100, 200], // Mock creation data
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(CreationFormular, { localVue, mocks, propsData, apolloProvider })
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a DIV element with the class.component-creation-formular', () => {
|
||||
expect(wrapper.find('.component-creation-formular').exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('text and value form props', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(CreationFormular, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData: {
|
||||
creationUserData: { memo: 'Memo from property', amount: 42 },
|
||||
...propsData,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('has text taken from props', () => {
|
||||
expect(wrapper.vm.text).toBe('Memo from property')
|
||||
})
|
||||
|
||||
it('has value taken from props', () => {
|
||||
expect(wrapper.vm.value).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('radio buttons to selcet month', () => {
|
||||
it('has three radio buttons', () => {
|
||||
expect(wrapper.findAll('input[type="radio"]').length).toBe(3)
|
||||
})
|
||||
|
||||
describe('with single creation', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({
|
||||
type: 'singleCreation',
|
||||
creation: [200, 400, 600],
|
||||
item: { email: 'benjamin@bluemchen.de' },
|
||||
})
|
||||
await wrapper.findAll('input[type="radio"]').at(1).setChecked()
|
||||
await wrapper.find('input[type="number"]').setValue(90)
|
||||
})
|
||||
|
||||
describe('first radio button', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('input[type="radio"]').at(0).setChecked()
|
||||
await wrapper.find('textarea').setValue('Test create coins')
|
||||
})
|
||||
|
||||
it('sets rangeMax to 200', () => {
|
||||
expect(wrapper.vm.rangeMax).toBe(200)
|
||||
})
|
||||
|
||||
describe('sendForm', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
})
|
||||
|
||||
it('sends ... to apollo', () => {
|
||||
expect(adminCreateContributionMock).toBeCalledWith({
|
||||
email: 'benjamin@bluemchen.de',
|
||||
creationDate: getCreationDate(2),
|
||||
amount: 90,
|
||||
memo: 'Test create coins',
|
||||
})
|
||||
})
|
||||
|
||||
it('emits update-user-data', () => {
|
||||
expect(wrapper.emitted('update-user-data')).toEqual([
|
||||
[{ email: 'benjamin@bluemchen.de' }, [0, 0, 0]],
|
||||
])
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith([
|
||||
'creation_form.toasted',
|
||||
{ email: 'benjamin@bluemchen.de', value: '90' },
|
||||
])
|
||||
})
|
||||
|
||||
it('updates open creations in store', () => {
|
||||
expect(stateCommitMock).toBeCalledWith('openCreationsPlus', 1)
|
||||
})
|
||||
|
||||
it('resets the form data', () => {
|
||||
expect(wrapper.vm.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendForm with server error', () => {
|
||||
beforeEach(async () => {
|
||||
adminCreateContributionMock.mockRejectedValueOnce({ message: 'Ouch!' })
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouch!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Negativ value', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ value: -20 })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty text', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ text: '' })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text length less than 10', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ text: 'Try this' })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('second radio button', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('input[type="radio"]').at(1).setChecked()
|
||||
})
|
||||
|
||||
it('sets rangeMin to 0', () => {
|
||||
expect(wrapper.vm.rangeMin).toBe(0)
|
||||
})
|
||||
|
||||
it('sets rangeMax to 400', () => {
|
||||
expect(wrapper.vm.rangeMax).toBe(400)
|
||||
})
|
||||
|
||||
describe('sendForm', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
})
|
||||
|
||||
it('sends ... to apollo', () => {
|
||||
expect(adminCreateContributionMock).toBeCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Negativ value', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ value: -20 })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty text', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ text: '' })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text length less than 10', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ text: 'Try this' })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('third radio button', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('input[type="radio"]').at(2).setChecked()
|
||||
})
|
||||
|
||||
it('sets rangeMin to 0', () => {
|
||||
expect(wrapper.vm.rangeMin).toBe(0)
|
||||
})
|
||||
|
||||
it('sets rangeMax to 400', () => {
|
||||
expect(wrapper.vm.rangeMax).toBe(600)
|
||||
})
|
||||
|
||||
describe('sendForm', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
})
|
||||
|
||||
it('sends mutation to apollo', () => {
|
||||
expect(adminCreateContributionMock).toBeCalled()
|
||||
})
|
||||
|
||||
it('toast success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalled()
|
||||
})
|
||||
|
||||
it('store commit openCreationPlus', () => {
|
||||
expect(stateCommitMock).toBeCalledWith('openCreationsPlus', 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Negativ value', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ value: -20 })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty text', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ text: '' })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text length less than 10', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
|
||||
await wrapper.setData({ rangeMin: 180 })
|
||||
await wrapper.setData({ text: 'Try this' })
|
||||
})
|
||||
|
||||
it('has no submit button', async () => {
|
||||
expect(await wrapper.find('.test-submit').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('renders correctly', () => {
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes with default values', () => {
|
||||
expect(wrapper.vm.text).toBe('')
|
||||
expect(wrapper.vm.value).toBe(0)
|
||||
expect(wrapper.vm.selected).toBe(null)
|
||||
})
|
||||
|
||||
it('updates radio options based on creationDateObjects', async () => {
|
||||
await nextTick()
|
||||
expect(wrapper.vm.radioOptions).toHaveLength(2)
|
||||
expect(wrapper.vm.radioOptions[0].name).toContain('Jan')
|
||||
expect(wrapper.vm.radioOptions[1].name).toContain('Feb')
|
||||
})
|
||||
|
||||
it('handles month selection', async () => {
|
||||
const radioGroup = wrapper.findComponent({ name: 'BFormRadioGroup' })
|
||||
await radioGroup.vm.$emit('update:modelValue', {
|
||||
short: 'Jan',
|
||||
year: '2024',
|
||||
date: '2024-01-01',
|
||||
creation: 100,
|
||||
})
|
||||
expect(wrapper.vm.selected).toEqual({
|
||||
short: 'Jan',
|
||||
year: '2024',
|
||||
date: '2024-01-01',
|
||||
creation: 100,
|
||||
})
|
||||
expect(wrapper.vm.text).toBe('creation_form.creation_for Jan 2024')
|
||||
})
|
||||
|
||||
it('disables submit button when form is invalid', async () => {
|
||||
wrapper.vm.selected = null
|
||||
wrapper.vm.value = 0
|
||||
wrapper.vm.text = ''
|
||||
await wrapper.vm.$nextTick()
|
||||
const submitButton = wrapper.find('.test-submit')
|
||||
expect(submitButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables submit button when form is valid', async () => {
|
||||
wrapper.vm.selected = { short: 'Jan', year: '2024', date: '2024-01-01', creation: 100 }
|
||||
wrapper.vm.value = 100
|
||||
wrapper.vm.text = 'Valid text input'
|
||||
await wrapper.vm.$nextTick()
|
||||
const submitButton = wrapper.find('.test-submit')
|
||||
expect(submitButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets form on reset button click', async () => {
|
||||
wrapper.vm.selected = { short: 'Jan', year: '2024', date: '2024-01-01', creation: 100 }
|
||||
wrapper.vm.value = 100
|
||||
wrapper.vm.text = 'Some text'
|
||||
await wrapper.vm.$nextTick()
|
||||
const resetButton = wrapper.find('button[type="reset"]')
|
||||
await resetButton.trigger('click')
|
||||
expect(wrapper.vm.selected).toBe(null)
|
||||
expect(wrapper.vm.value).toBe(0)
|
||||
expect(wrapper.vm.text).toBe('')
|
||||
})
|
||||
|
||||
it('displays different button text based on pagetype', async () => {
|
||||
await wrapper.setProps({ pagetype: 'PageCreationConfirm' })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.vm.submitBtnText).toBe('creation_form.update_creation')
|
||||
|
||||
await wrapper.setProps({ pagetype: '' })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.vm.submitBtnText).toBe('creation_form.submit_creation')
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,178 +2,202 @@
|
||||
<div class="component-creation-formular">
|
||||
{{ $t('creation_form.form') }}
|
||||
<div class="shadow p-3 mb-5 bg-white rounded">
|
||||
<b-form ref="creationForm">
|
||||
<div class="ml-4">
|
||||
<BForm ref="creationForm">
|
||||
<div class="m-4 mt-0">
|
||||
<label>{{ $t('creation_form.select_month') }}</label>
|
||||
</div>
|
||||
<b-row class="ml-4">
|
||||
<b-form-radio-group
|
||||
<BFormRadioGroup
|
||||
id="radio-group-month-selection"
|
||||
v-model="selected"
|
||||
:options="radioOptions"
|
||||
value-field="item"
|
||||
text-field="name"
|
||||
name="month-selection"
|
||||
></b-form-radio-group>
|
||||
</b-row>
|
||||
<b-row class="m-4" v-show="selected !== ''">
|
||||
/>
|
||||
</div>
|
||||
<div v-if="selected" class="m-4 d-flex">
|
||||
<label>{{ $t('creation_form.select_value') }}</label>
|
||||
<div>
|
||||
<b-input-group prepend="GDD" append=".00">
|
||||
<b-form-input
|
||||
type="number"
|
||||
v-model="value"
|
||||
:min="rangeMin"
|
||||
:max="rangeMax"
|
||||
></b-form-input>
|
||||
</b-input-group>
|
||||
<b-input-group prepend="0" :append="String(rangeMax)" class="mt-3">
|
||||
<b-form-input
|
||||
type="range"
|
||||
v-model="value"
|
||||
:min="rangeMin"
|
||||
:max="rangeMax"
|
||||
step="10"
|
||||
></b-form-input>
|
||||
</b-input-group>
|
||||
<BInputGroup prepend="GDD" append=".00">
|
||||
<BFormInput v-model="value" type="number" :min="rangeMin" :max="rangeMax" />
|
||||
</BInputGroup>
|
||||
<BInputGroup
|
||||
prepend="0"
|
||||
:append="String(rangeMax)"
|
||||
class="mt-3 flex-nowrap align-items-center"
|
||||
>
|
||||
<BFormInput v-model="value" type="range" :min="rangeMin" :max="rangeMax" step="10" />
|
||||
</BInputGroup>
|
||||
</div>
|
||||
</b-row>
|
||||
</div>
|
||||
<div class="m-4">
|
||||
<label>{{ $t('creation_form.enter_text') }}</label>
|
||||
<div>
|
||||
<b-form-textarea
|
||||
<BFormTextarea
|
||||
id="textarea-state"
|
||||
v-model="text"
|
||||
:state="text.length >= 10"
|
||||
:placeholder="$t('creation_form.min_characters')"
|
||||
rows="3"
|
||||
></b-form-textarea>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<b-row class="m-4">
|
||||
<b-col class="text-left">
|
||||
<b-button type="reset" variant="danger" @click="$refs.creationForm.reset()">
|
||||
{{ $t('creation_form.reset') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-center">
|
||||
<div class="text-right">
|
||||
<b-button
|
||||
v-if="pagetype === 'PageCreationConfirm'"
|
||||
type="button"
|
||||
variant="success"
|
||||
class="test-submit"
|
||||
@click="submitCreation"
|
||||
:disabled="selected === '' || value <= 0 || text.length < 10"
|
||||
>
|
||||
{{ $t('creation_form.update_creation') }}
|
||||
</b-button>
|
||||
<b-button
|
||||
v-else
|
||||
type="button"
|
||||
variant="success"
|
||||
class="test-submit"
|
||||
@click="submitCreation"
|
||||
:disabled="selected === '' || value <= 0 || text.length < 10"
|
||||
>
|
||||
{{ $t('creation_form.submit_creation') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-form>
|
||||
<div class="buttons-wrapper d-flex justify-content-between">
|
||||
<BButton type="reset" variant="danger" @click="onReset()">
|
||||
{{ $t('creation_form.reset') }}
|
||||
</BButton>
|
||||
<div>
|
||||
<BButton
|
||||
type="button"
|
||||
variant="success"
|
||||
class="test-submit"
|
||||
:disabled="disabled"
|
||||
@click="submitCreation"
|
||||
>
|
||||
{{ submitBtnText }}
|
||||
</BButton>
|
||||
</div>
|
||||
</div>
|
||||
</BForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import { useStore } from 'vuex'
|
||||
import { adminCreateContribution } from '../graphql/adminCreateContribution'
|
||||
import { creationMonths } from '../mixins/creationMonths'
|
||||
export default {
|
||||
name: 'CreationFormular',
|
||||
mixins: [creationMonths],
|
||||
props: {
|
||||
pagetype: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
creationUserData: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
import { adminOpenCreations } from '../graphql/adminOpenCreations'
|
||||
import useCreationMonths from '../composables/useCreationMonths'
|
||||
import {
|
||||
BFormInput,
|
||||
BFormRadioGroup,
|
||||
BForm,
|
||||
BInputGroup,
|
||||
BButton,
|
||||
BFormTextarea,
|
||||
} from 'bootstrap-vue-next'
|
||||
|
||||
const { creationDateObjects } = useCreationMonths()
|
||||
const { toastError, toastSuccess } = useAppToast()
|
||||
|
||||
const props = defineProps({
|
||||
pagetype: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
data() {
|
||||
item: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
creationUserData: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
creation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
|
||||
const text = ref(!props.creationUserData.memo ? '' : props.creationUserData.memo)
|
||||
const value = ref(!props.creationUserData.amount ? 0 : props.creationUserData.amount)
|
||||
const rangeMin = ref(0)
|
||||
const rangeMax = ref(1000)
|
||||
const selected = ref(null)
|
||||
const creationForm = ref(null)
|
||||
|
||||
const radioOptions = computed(() => {
|
||||
return creationDateObjects.value.map((obj, idx) => {
|
||||
return {
|
||||
text: !this.creationUserData.memo ? '' : this.creationUserData.memo,
|
||||
value: !this.creationUserData.amount ? 0 : this.creationUserData.amount,
|
||||
rangeMin: 0,
|
||||
rangeMax: 1000,
|
||||
selected: '',
|
||||
userId: this.item.userId,
|
||||
item: { ...obj, creation: props.creation[idx] },
|
||||
name: obj.short + (props.creation[idx] ? ' ' + props.creation[idx] + ' GDD' : ''),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const disabled = computed(() => {
|
||||
return selected.value === '' || value.value <= 0 || text.value.length < 10
|
||||
})
|
||||
|
||||
const submitBtnText = computed(() => {
|
||||
return props.pagetype === 'PageCreationConfirm'
|
||||
? t('creation_form.update_creation')
|
||||
: t('creation_form.submit_creation')
|
||||
})
|
||||
|
||||
const updateRadioSelected = (name) => {
|
||||
text.value = `${t('creation_form.creation_for')} ${name?.short} ${name?.year}`
|
||||
rangeMin.value = 0
|
||||
rangeMax.value = Number(name?.creation)
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
text.value = ''
|
||||
value.value = 0
|
||||
selected.value = null
|
||||
}
|
||||
const { mutate: createContribution } = useMutation(adminCreateContribution)
|
||||
|
||||
const { refetch: refetchCreations } = useQuery(adminOpenCreations, { userId: props.item.userId })
|
||||
const emit = defineEmits(['update-user-data'])
|
||||
|
||||
const submitCreation = async () => {
|
||||
try {
|
||||
const result = await createContribution({
|
||||
email: props.item.email,
|
||||
creationDate: selected.value.date,
|
||||
amount: Number(value.value),
|
||||
memo: text.value,
|
||||
})
|
||||
|
||||
emit('update-user-data', props.item, result.data.adminCreateContribution)
|
||||
|
||||
store.commit('openCreationsPlus', 1)
|
||||
|
||||
toastSuccess(
|
||||
t('creation_form.toasted', {
|
||||
value: value.value,
|
||||
email: props.item.email,
|
||||
}),
|
||||
)
|
||||
|
||||
onReset()
|
||||
} catch (error) {
|
||||
toastError(error.message)
|
||||
onReset()
|
||||
} finally {
|
||||
refetchCreations()
|
||||
selected.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selected.value,
|
||||
async (newValue, oldValue) => {
|
||||
if (newValue !== oldValue && selected.value !== '' && selected.value !== null) {
|
||||
updateRadioSelected(newValue)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateRadioSelected(name) {
|
||||
// do we want to reset the memo everytime the month changes?
|
||||
this.text = this.$t('creation_form.creation_for') + ' ' + name.short + ' ' + name.year
|
||||
this.rangeMin = 0
|
||||
this.rangeMax = Number(name.creation)
|
||||
},
|
||||
submitCreation() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: adminCreateContribution,
|
||||
variables: {
|
||||
email: this.item.email,
|
||||
creationDate: this.selected.date,
|
||||
amount: Number(this.value),
|
||||
memo: this.text,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.$emit('update-user-data', this.item, result.data.adminCreateContribution)
|
||||
this.$store.commit('openCreationsPlus', 1)
|
||||
this.toastSuccess(
|
||||
this.$t('creation_form.toasted', {
|
||||
value: this.value,
|
||||
email: this.item.email,
|
||||
}),
|
||||
)
|
||||
// what is this? Tests says that this.text is not reseted
|
||||
this.$refs.creationForm.reset()
|
||||
this.value = 0
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
this.$refs.creationForm.reset()
|
||||
this.value = 0
|
||||
})
|
||||
.finally(() => {
|
||||
this.$apollo.queries.OpenCreations.refetch()
|
||||
this.selected = ''
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selected() {
|
||||
this.updateRadioSelected(this.selected)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({ submitCreation })
|
||||
</script>
|
||||
<style scoped>
|
||||
.buttons-wrapper {
|
||||
margin: 1.5rem 2.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,139 +1,104 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CreationTransactionList from './CreationTransactionList'
|
||||
import { toastErrorSpy } from '../../test/testSetup'
|
||||
import VueApollo from 'vue-apollo'
|
||||
import { createMockClient } from 'mock-apollo-client'
|
||||
import { adminListContributions } from '../graphql/adminListContributions'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import CreationTransactionList from './CreationTransactionList.vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { adminListContributionsShort } from '../graphql/adminListContributions.graphql'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
const mockClient = createMockClient()
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: mockClient,
|
||||
})
|
||||
|
||||
const localVue = global.localVue
|
||||
localVue.use(VueApollo)
|
||||
|
||||
const defaultData = () => {
|
||||
return {
|
||||
adminListContributions: {
|
||||
contributionCount: 2,
|
||||
contributionList: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Bibi',
|
||||
lastName: 'Bloxberg',
|
||||
userId: 99,
|
||||
email: 'bibi@bloxberg.de',
|
||||
amount: 500,
|
||||
memo: 'Danke für alles',
|
||||
date: new Date(),
|
||||
moderator: 1,
|
||||
status: 'PENDING',
|
||||
creation: [500, 500, 500],
|
||||
messagesCount: 0,
|
||||
deniedBy: null,
|
||||
deniedAt: null,
|
||||
confirmedBy: null,
|
||||
confirmedAt: null,
|
||||
contributionDate: new Date(),
|
||||
deletedBy: null,
|
||||
deletedAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
createdAt: new Date(),
|
||||
moderatorId: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
firstName: 'Räuber',
|
||||
lastName: 'Hotzenplotz',
|
||||
userId: 100,
|
||||
email: 'raeuber@hotzenplotz.de',
|
||||
amount: 1000000,
|
||||
memo: 'Gut Ergattert',
|
||||
date: new Date(),
|
||||
moderator: 1,
|
||||
status: 'PENDING',
|
||||
creation: [500, 500, 500],
|
||||
messagesCount: 0,
|
||||
deniedBy: null,
|
||||
deniedAt: null,
|
||||
confirmedBy: null,
|
||||
confirmedAt: new Date(),
|
||||
contributionDate: new Date(),
|
||||
deletedBy: null,
|
||||
deletedAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
createdAt: new Date(),
|
||||
moderatorId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$d: jest.fn((t) => t),
|
||||
$t: jest.fn((t) => t),
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
userId: 1,
|
||||
fields: ['createdAt', 'contributionDate', 'confirmedAt', 'amount', 'memo'],
|
||||
}
|
||||
vi.mock('@vue/apollo-composable')
|
||||
vi.mock('vue-i18n')
|
||||
vi.mock('@/composables/useToast')
|
||||
|
||||
describe('CreationTransactionList', () => {
|
||||
let wrapper
|
||||
const mockResult = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockT = vi.fn((key) => key)
|
||||
const mockD = vi.fn((date) => date.toISOString())
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
const adminListContributionsMock = jest.fn()
|
||||
mockClient.setRequestHandler(
|
||||
adminListContributions,
|
||||
adminListContributionsMock
|
||||
.mockRejectedValueOnce({ message: 'Ouch!' })
|
||||
.mockResolvedValue({ data: defaultData() }),
|
||||
)
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(CreationTransactionList, { localVue, mocks, propsData, apolloProvider })
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = Wrapper()
|
||||
beforeEach(() => {
|
||||
useQuery.mockReturnValue({
|
||||
result: mockResult,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
describe('server error', () => {
|
||||
it('toast error', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouch!')
|
||||
})
|
||||
useI18n.mockReturnValue({
|
||||
t: mockT,
|
||||
d: mockD,
|
||||
})
|
||||
|
||||
describe('sever success', () => {
|
||||
it('sends query to Apollo when created', () => {
|
||||
expect(adminListContributionsMock).toBeCalledWith({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
order: 'DESC',
|
||||
userId: 1,
|
||||
})
|
||||
})
|
||||
useAppToast.mockReturnValue({
|
||||
toastError: mockToastError,
|
||||
})
|
||||
|
||||
it('has two values for the transaction', () => {
|
||||
expect(wrapper.find('tbody').findAll('tr').length).toBe(2)
|
||||
})
|
||||
|
||||
describe('watch currentPage', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.setData({ currentPage: 2 })
|
||||
})
|
||||
|
||||
it('returns the string in normal order if reversed property is not true', () => {
|
||||
expect(wrapper.vm.currentPage).toBe(2)
|
||||
})
|
||||
})
|
||||
wrapper = mount(CreationTransactionList, {
|
||||
props: {
|
||||
userId: 1,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BTable: true,
|
||||
BPagination: true,
|
||||
BButton: true,
|
||||
BCollapse: true,
|
||||
},
|
||||
directives: {
|
||||
'b-toggle': {},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.component-creation-transaction-list').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes with correct data', () => {
|
||||
expect(wrapper.vm.currentPage).toBe(1)
|
||||
expect(wrapper.vm.perPage).toBe(10)
|
||||
expect(wrapper.vm.items).toEqual([])
|
||||
expect(wrapper.vm.rows).toBe(0)
|
||||
})
|
||||
|
||||
it('calls useQuery with correct parameters', () => {
|
||||
expect(useQuery).toHaveBeenCalled()
|
||||
const call = useQuery.mock.calls[0]
|
||||
expect(call[0]).toBe(adminListContributionsShort)
|
||||
expect(call[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
filter: {
|
||||
userId: 1,
|
||||
},
|
||||
paginated: {
|
||||
currentPage: expect.any(Number),
|
||||
pageSize: expect.any(Number),
|
||||
order: 'DESC',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('refetches data when currentPage changes', async () => {
|
||||
wrapper.vm.currentPage = 2
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('formats fields correctly', () => {
|
||||
const fields = wrapper.vm.fields
|
||||
expect(fields).toHaveLength(6)
|
||||
expect(fields[0].key).toBe('createdAt')
|
||||
expect(fields[1].key).toBe('contributionDate')
|
||||
expect(fields[2].key).toBe('confirmedAt')
|
||||
expect(fields[3].key).toBe('status')
|
||||
expect(fields[4].key).toBe('amount')
|
||||
expect(fields[5].key).toBe('memo')
|
||||
})
|
||||
|
||||
it('formats amount correctly', () => {
|
||||
const amountField = wrapper.vm.fields.find((f) => f.key === 'amount')
|
||||
expect(amountField.formatter(100)).toBe('100 GDD')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,118 +1,117 @@
|
||||
<template>
|
||||
<div class="component-creation-transaction-list">
|
||||
<div class="h3">{{ $t('transactionlist.title') }}</div>
|
||||
<b-table striped hover :fields="fields" :items="items">
|
||||
<BTable striped hover :fields="fields" :items="items">
|
||||
<template #cell(contributionDate)="data">
|
||||
<div class="font-weight-bold">
|
||||
{{ $d(new Date(data.item.contributionDate), 'month') }}
|
||||
</div>
|
||||
<div>{{ $d(new Date(data.item.contributionDate)) }}</div>
|
||||
</template>
|
||||
</b-table>
|
||||
</BTable>
|
||||
<div>
|
||||
<b-pagination
|
||||
<BPagination
|
||||
v-model="currentPage"
|
||||
pills
|
||||
size="lg"
|
||||
v-model="currentPage"
|
||||
:per-page="perPage"
|
||||
:total-rows="rows"
|
||||
align="center"
|
||||
:hide-ellipsis="true"
|
||||
></b-pagination>
|
||||
<b-button v-b-toggle.collapse-1 variant="light" size="sm">{{ $t('help.help') }}</b-button>
|
||||
<b-collapse id="collapse-1" class="mt-2">
|
||||
/>
|
||||
<BButton v-b-toggle="'collapse-1'" variant="light" size="sm">{{ t('help.help') }}</BButton>
|
||||
<BCollapse id="collapse-1" class="mt-2">
|
||||
<div>
|
||||
{{ $t('transactionlist.submitted') }} {{ $t('math.equals') }}
|
||||
{{ $t('help.transactionlist.submitted') }}
|
||||
{{ t('transactionlist.submitted') }} {{ t('math.equals') }}
|
||||
{{ t('help.transactionlist.submitted') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('transactionlist.period') }} {{ $t('math.equals') }}
|
||||
{{ $t('help.transactionlist.periods') }}
|
||||
{{ t('transactionlist.period') }} {{ t('math.equals') }}
|
||||
{{ t('help.transactionlist.periods') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('transactionlist.confirmed') }} {{ $t('math.equals') }}
|
||||
{{ $t('help.transactionlist.confirmed') }}
|
||||
{{ t('transactionlist.confirmed') }} {{ t('math.equals') }}
|
||||
{{ t('help.transactionlist.confirmed') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('transactionlist.status') }} {{ $t('math.equals') }}
|
||||
{{ $t('help.transactionlist.status') }}
|
||||
{{ t('transactionlist.status') }} {{ t('math.equals') }}
|
||||
{{ t('help.transactionlist.status') }}
|
||||
</div>
|
||||
</b-collapse>
|
||||
</BCollapse>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { adminListContributions } from '../graphql/adminListContributions'
|
||||
export default {
|
||||
name: 'CreationTransactionList',
|
||||
props: {
|
||||
userId: { type: Number, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: [],
|
||||
rows: 0,
|
||||
currentPage: 1,
|
||||
perPage: 10,
|
||||
fields: [
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: this.$t('transactionlist.submitted'),
|
||||
formatter: (value, key, item) => {
|
||||
return this.$d(new Date(value))
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'contributionDate',
|
||||
label: this.$t('transactionlist.period'),
|
||||
},
|
||||
{
|
||||
key: 'confirmedAt',
|
||||
label: this.$t('transactionlist.confirmed'),
|
||||
formatter: (value, key, item) => {
|
||||
if (value) {
|
||||
return this.$d(new Date(value))
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: this.$t('transactionlist.status'),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: this.$t('transactionlist.amount'),
|
||||
formatter: (value, key, item) => {
|
||||
return `${value} GDD`
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: this.$t('transactionlist.memo'), class: 'text-break' },
|
||||
],
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
AdminListContributions: {
|
||||
query() {
|
||||
return adminListContributions
|
||||
},
|
||||
variables() {
|
||||
return {
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.perPage,
|
||||
order: 'DESC',
|
||||
userId: parseInt(this.userId),
|
||||
}
|
||||
},
|
||||
update({ adminListContributions }) {
|
||||
this.rows = adminListContributions.contributionCount
|
||||
this.items = adminListContributions.contributionList
|
||||
},
|
||||
error({ message }) {
|
||||
this.toastError(message)
|
||||
},
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { adminListContributionsShort } from '../graphql/adminListContributions.graphql'
|
||||
import { BTable, BPagination, BButton, BCollapse, vBToggle } from 'bootstrap-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
userId: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const items = ref([])
|
||||
const rows = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(10)
|
||||
|
||||
const fields = [
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: t('transactionlist.submitted'),
|
||||
formatter: (value) => {
|
||||
return new Date(value).toLocaleDateString()
|
||||
},
|
||||
},
|
||||
}
|
||||
{
|
||||
key: 'contributionDate',
|
||||
label: t('transactionlist.period'),
|
||||
},
|
||||
{
|
||||
key: 'confirmedAt',
|
||||
label: t('transactionlist.confirmed'),
|
||||
formatter: (value) => {
|
||||
return value ? new Date(value).toLocaleDateString() : null
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('transactionlist.status'),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: t('transactionlist.amount'),
|
||||
formatter: (value) => {
|
||||
return `${value} GDD`
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: t('transactionlist.memo'), class: 'text-break' },
|
||||
]
|
||||
|
||||
const { result, refetch } = useQuery(adminListContributionsShort, {
|
||||
filter: {
|
||||
userId: props.userId,
|
||||
},
|
||||
paginated: {
|
||||
currentPage: currentPage.value,
|
||||
pageSize: perPage.value,
|
||||
order: 'DESC',
|
||||
},
|
||||
})
|
||||
|
||||
watch(result, (newResult) => {
|
||||
if (newResult && newResult.adminListContributions) {
|
||||
rows.value = newResult.adminListContributions.contributionCount
|
||||
items.value = newResult.adminListContributions.contributionList
|
||||
}
|
||||
})
|
||||
|
||||
watch(currentPage, () => {
|
||||
refetch()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,220 +1,212 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DeletedUserFormular from './DeletedUserFormular'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import DeletedUserFormular from './DeletedUserFormular.vue'
|
||||
import { deleteUser } from '../graphql/deleteUser'
|
||||
import { unDeleteUser } from '../graphql/unDeleteUser'
|
||||
import { toastErrorSpy } from '../../test/testSetup'
|
||||
import { useApolloClient } from '@vue/apollo-composable'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import { createStore } from 'vuex'
|
||||
import { BButton } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
vi.mock('@vue/apollo-composable')
|
||||
vi.mock('vue-i18n')
|
||||
vi.mock('@/composables/useToast')
|
||||
|
||||
const date = new Date()
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
deleteUser: date,
|
||||
},
|
||||
})
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
$store: {
|
||||
const createVuexStore = (moderatorId = 0) => {
|
||||
return createStore({
|
||||
state: {
|
||||
moderator: {
|
||||
id: 0,
|
||||
id: moderatorId,
|
||||
name: 'test moderator',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
item: {},
|
||||
})
|
||||
}
|
||||
|
||||
describe('DeletedUserFormular', () => {
|
||||
let wrapper
|
||||
let spy
|
||||
let store
|
||||
const mockMutate = vi.fn()
|
||||
const mockT = vi.fn((key) => key)
|
||||
const mockToastError = vi.fn()
|
||||
const date = new Date()
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(DeletedUserFormular, { localVue, mocks, propsData })
|
||||
}
|
||||
beforeEach(() => {
|
||||
store = createVuexStore()
|
||||
|
||||
describe('mount', () => {
|
||||
useApolloClient.mockReturnValue({
|
||||
client: {
|
||||
mutate: mockMutate,
|
||||
},
|
||||
})
|
||||
|
||||
useI18n.mockReturnValue({
|
||||
t: mockT,
|
||||
})
|
||||
|
||||
useAppToast.mockReturnValue({
|
||||
toastError: mockToastError,
|
||||
})
|
||||
|
||||
wrapper = mount(DeletedUserFormular, {
|
||||
props: {
|
||||
item: {
|
||||
userId: 1,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
global: {
|
||||
plugins: [store],
|
||||
mocks: {
|
||||
$t: mockT,
|
||||
},
|
||||
stubs: {
|
||||
BButton,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.deleted-user-formular').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('when user is not a moderator', () => {
|
||||
it('shows delete button when user is not deleted', () => {
|
||||
expect(wrapper.find('button').text()).toBe('delete_user')
|
||||
})
|
||||
|
||||
it('shows undelete button when user is deleted', async () => {
|
||||
await wrapper.setProps({
|
||||
item: {
|
||||
userId: 1,
|
||||
deletedAt: date,
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('button').text()).toBe('undelete_user')
|
||||
})
|
||||
|
||||
it('emits show-delete-modal when delete button is clicked', async () => {
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('show-delete-modal')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits show-undelete-modal when undelete button is clicked', async () => {
|
||||
await wrapper.setProps({
|
||||
item: {
|
||||
userId: 1,
|
||||
deletedAt: date,
|
||||
},
|
||||
})
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('show-undelete-modal')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when user is a moderator', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a DIV element with the class.delete-user-formular', () => {
|
||||
expect(wrapper.find('.deleted-user-formular').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('delete self', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({
|
||||
item: {
|
||||
userId: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a text that you cannot delete yourself', () => {
|
||||
expect(wrapper.text()).toBe('removeNotSelf')
|
||||
})
|
||||
|
||||
it('has no "delete_user" button', () => {
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete other user', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({
|
||||
store = createVuexStore(1)
|
||||
wrapper = mount(DeletedUserFormular, {
|
||||
props: {
|
||||
item: {
|
||||
userId: 1,
|
||||
deletedAt: null,
|
||||
},
|
||||
static: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the text "delete_user"', () => {
|
||||
expect(wrapper.text()).toBe('delete_user')
|
||||
})
|
||||
|
||||
it('has a "delete_user" button', () => {
|
||||
expect(wrapper.find('button').text()).toBe('delete_user')
|
||||
})
|
||||
|
||||
describe('click on "delete_user" button', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('calls the modal', () => {
|
||||
expect(wrapper.emitted('showDeleteModal'))
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('confirm delete with success', () => {
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: deleteUser,
|
||||
variables: {
|
||||
userId: 1,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits update deleted At', () => {
|
||||
expect(wrapper.emitted('updateDeletedAt')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
deletedAt: date,
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm delete with error', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
},
|
||||
global: {
|
||||
plugins: [store],
|
||||
mocks: {
|
||||
$t: mockT,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('recover user', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({
|
||||
item: {
|
||||
it('shows removeNotSelf message', () => {
|
||||
expect(wrapper.text()).toBe('removeNotSelf')
|
||||
})
|
||||
|
||||
it('does not show any button', () => {
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUserMutation', () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockResolvedValue({
|
||||
data: {
|
||||
deleteUser: date,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('calls the mutation with correct parameters', async () => {
|
||||
await wrapper.vm.deleteUserMutation()
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
mutation: deleteUser,
|
||||
variables: {
|
||||
userId: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('emits update-deleted-at with correct data on success', async () => {
|
||||
await wrapper.vm.deleteUserMutation()
|
||||
expect(wrapper.emitted('update-deleted-at')).toEqual([
|
||||
[
|
||||
{
|
||||
userId: 1,
|
||||
deletedAt: date,
|
||||
},
|
||||
})
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('calls toastError on failure', async () => {
|
||||
const error = new Error('Delete failed')
|
||||
mockMutate.mockRejectedValueOnce(error)
|
||||
await wrapper.vm.deleteUserMutation()
|
||||
expect(mockToastError).toHaveBeenCalledWith('Delete failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('undeleteUserMutation', () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockResolvedValue({
|
||||
data: {
|
||||
unDeleteUser: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the text "undelete_user"', () => {
|
||||
expect(wrapper.text()).toBe('undelete_user')
|
||||
it('calls the mutation with correct parameters', async () => {
|
||||
await wrapper.vm.undeleteUserMutation()
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
mutation: unDeleteUser,
|
||||
variables: {
|
||||
userId: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('has a "undelete_user" button', () => {
|
||||
expect(wrapper.find('button').text()).toBe('undelete_user')
|
||||
})
|
||||
it('emits update-deleted-at with correct data on success', async () => {
|
||||
await wrapper.vm.undeleteUserMutation()
|
||||
expect(wrapper.emitted('update-deleted-at')).toEqual([
|
||||
[
|
||||
{
|
||||
userId: 1,
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
describe('click on "undelete_user" button', () => {
|
||||
beforeEach(async () => {
|
||||
apolloMutateMock.mockResolvedValue({
|
||||
data: {
|
||||
unDeleteUser: null,
|
||||
},
|
||||
})
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
})
|
||||
|
||||
it('calls the modal', () => {
|
||||
expect(wrapper.emitted('showUndeleteModal'))
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('confirm recover with success', () => {
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: unDeleteUser,
|
||||
variables: {
|
||||
userId: 1,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits update deleted At', () => {
|
||||
expect(wrapper.emitted('updateDeletedAt')).toMatchObject(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
deletedAt: null,
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm recover with error', () => {
|
||||
beforeEach(async () => {
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
it('calls toastError on failure', async () => {
|
||||
const error = new Error('Undelete failed')
|
||||
mockMutate.mockRejectedValueOnce(error)
|
||||
await wrapper.vm.undeleteUserMutation()
|
||||
expect(mockToastError).toHaveBeenCalledWith('Undelete failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,128 +1,97 @@
|
||||
<template>
|
||||
<div class="deleted-user-formular">
|
||||
<div v-if="item.userId === $store.state.moderator.id" class="mt-5 mb-5">
|
||||
<div v-if="isUserModerator" class="mt-5 mb-5">
|
||||
{{ $t('removeNotSelf') }}
|
||||
</div>
|
||||
<div v-else class="mt-5">
|
||||
<div class="mt-3 mb-5">
|
||||
<b-button
|
||||
<BButton
|
||||
v-if="!item.deletedAt"
|
||||
variant="danger"
|
||||
v-b-modal.delete-user-modal
|
||||
@click="showDeleteModal()"
|
||||
variant="danger"
|
||||
@click="showDeleteModal"
|
||||
>
|
||||
{{ $t('delete_user') }}
|
||||
</b-button>
|
||||
<b-button v-else variant="success" v-b-modal.delete-user-modal @click="showUndeleteModal()">
|
||||
</BButton>
|
||||
<BButton v-else v-b-modal.delete-user-modal variant="success" @click="showUndeleteModal">
|
||||
{{ $t('undelete_user') }}
|
||||
</b-button>
|
||||
</BButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { useApolloClient } from '@vue/apollo-composable'
|
||||
import { BButton, vBModal } from 'bootstrap-vue-next'
|
||||
import { deleteUser } from '../graphql/deleteUser'
|
||||
import { unDeleteUser } from '../graphql/unDeleteUser'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
export default {
|
||||
name: 'DeletedUser',
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
showDeleteModal() {
|
||||
this.$bvModal
|
||||
.msgBoxConfirm(
|
||||
this.$t('overlay.deleteUser.question', {
|
||||
username: `${this.item.firstName} ${this.item.lastName}`,
|
||||
}),
|
||||
{
|
||||
cancelTitle: this.$t('overlay.cancel'),
|
||||
centered: true,
|
||||
hideHeaderClose: true,
|
||||
title: this.$t('overlay.deleteUser.title'),
|
||||
okTitle: this.$t('overlay.deleteUser.yes'),
|
||||
okVariant: 'danger',
|
||||
static: true,
|
||||
},
|
||||
)
|
||||
.then((okClicked) => {
|
||||
if (okClicked) {
|
||||
this.deleteUser()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
showUndeleteModal() {
|
||||
this.$bvModal
|
||||
.msgBoxConfirm(
|
||||
this.$t('overlay.undeleteUser.question', {
|
||||
username: `${this.item.firstName} ${this.item.lastName}`,
|
||||
}),
|
||||
{
|
||||
cancelTitle: this.$t('overlay.cancel'),
|
||||
centered: true,
|
||||
hideHeaderClose: true,
|
||||
title: this.$t('overlay.undeleteUser.title'),
|
||||
okTitle: this.$t('overlay.undeleteUser.yes'),
|
||||
okVariant: 'success',
|
||||
},
|
||||
)
|
||||
.then((okClicked) => {
|
||||
if (okClicked) {
|
||||
this.unDeleteUser()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
deleteUser() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: deleteUser,
|
||||
variables: {
|
||||
userId: this.item.userId,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.$emit('updateDeletedAt', {
|
||||
userId: this.item.userId,
|
||||
deletedAt: result.data.deleteUser,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
unDeleteUser() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: unDeleteUser,
|
||||
variables: {
|
||||
userId: this.item.userId,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.$emit('updateDeletedAt', {
|
||||
userId: this.item.userId,
|
||||
deletedAt: result.data.unDeleteUser,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-deleted-at', 'show-delete-modal', 'show-undelete-modal'])
|
||||
|
||||
const { client } = useApolloClient()
|
||||
const store = useStore()
|
||||
const { toastError } = useAppToast()
|
||||
|
||||
const isUserModerator = computed(() => props.item.userId === store.state.moderator.id)
|
||||
|
||||
const showDeleteModal = () => {
|
||||
emit('show-delete-modal')
|
||||
}
|
||||
|
||||
const showUndeleteModal = () => {
|
||||
emit('show-undelete-modal')
|
||||
}
|
||||
|
||||
const deleteUserMutation = async () => {
|
||||
try {
|
||||
const result = await client.mutate({
|
||||
mutation: deleteUser,
|
||||
variables: {
|
||||
userId: props.item.userId,
|
||||
},
|
||||
})
|
||||
emit('update-deleted-at', {
|
||||
userId: props.item.userId,
|
||||
deletedAt: result.data.deleteUser,
|
||||
})
|
||||
} catch (error) {
|
||||
toastError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const undeleteUserMutation = async () => {
|
||||
try {
|
||||
const result = await client.mutate({
|
||||
mutation: unDeleteUser,
|
||||
variables: {
|
||||
userId: props.item.userId,
|
||||
},
|
||||
})
|
||||
emit('update-deleted-at', {
|
||||
userId: props.item.userId,
|
||||
deletedAt: result.data.unDeleteUser,
|
||||
})
|
||||
} catch (error) {
|
||||
toastError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ deleteUserMutation, undeleteUserMutation })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.input-group-text {
|
||||
background-color: rgb(255, 252, 205);
|
||||
background-color: rgb(255 252 205);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,163 +1,156 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import EditCreationFormular from './EditCreationFormular'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
import VueApollo from 'vue-apollo'
|
||||
import { createMockClient } from 'mock-apollo-client'
|
||||
import { adminOpenCreations } from '../graphql/adminOpenCreations'
|
||||
import { adminUpdateContribution } from '../graphql/adminUpdateContribution'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import EditCreationFormular from './EditCreationFormular.vue'
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import useCreationMonths from '@/composables/useCreationMonths'
|
||||
import {
|
||||
BButton,
|
||||
BCol,
|
||||
BForm,
|
||||
BFormInput,
|
||||
BFormRadioGroup,
|
||||
BFormTextarea,
|
||||
BInputGroup,
|
||||
BRow,
|
||||
} from 'bootstrap-vue-next'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mockClient = createMockClient()
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: mockClient,
|
||||
})
|
||||
|
||||
const localVue = global.localVue
|
||||
localVue.use(VueApollo)
|
||||
|
||||
const stateCommitMock = jest.fn()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => {
|
||||
const date = new Date(d)
|
||||
return date.toISOString().split('T')[0]
|
||||
}),
|
||||
$store: {
|
||||
commit: stateCommitMock,
|
||||
},
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const getCreationDate = (sub) => {
|
||||
const date = sub === 0 ? now : new Date(now.getFullYear(), now.getMonth() - sub, 1, 0)
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
creationUserData: {
|
||||
memo: 'Test schöpfung 1',
|
||||
amount: 100,
|
||||
date: getCreationDate(0),
|
||||
},
|
||||
item: {
|
||||
id: 0,
|
||||
amount: '300',
|
||||
contributionDate: `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`,
|
||||
},
|
||||
}
|
||||
|
||||
const data = () => {
|
||||
return { creation: ['1000', '1000', '400'] }
|
||||
}
|
||||
vi.mock('@vue/apollo-composable')
|
||||
vi.mock('vue-i18n')
|
||||
vi.mock('@/composables/useToast')
|
||||
vi.mock('@/composables/useCreationMonths')
|
||||
|
||||
describe('EditCreationFormular', () => {
|
||||
let wrapper
|
||||
|
||||
const adminUpdateContributionMock = jest.fn()
|
||||
const adminOpenCreationsMock = jest.fn()
|
||||
mockClient.setRequestHandler(
|
||||
adminOpenCreations,
|
||||
adminOpenCreationsMock.mockResolvedValue({
|
||||
data: {
|
||||
adminOpenCreations: [
|
||||
{
|
||||
month: new Date(now.getFullYear(), now.getMonth() - 2).getMonth(),
|
||||
year: new Date(now.getFullYear(), now.getMonth() - 2).getFullYear(),
|
||||
amount: '1000',
|
||||
},
|
||||
{
|
||||
month: new Date(now.getFullYear(), now.getMonth() - 1).getMonth(),
|
||||
year: new Date(now.getFullYear(), now.getMonth() - 1).getFullYear(),
|
||||
amount: '1000',
|
||||
},
|
||||
{
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
amount: '400',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
mockClient.setRequestHandler(
|
||||
adminUpdateContribution,
|
||||
adminUpdateContributionMock.mockResolvedValue({
|
||||
data: {
|
||||
adminUpdateContribution: {
|
||||
amount: '600',
|
||||
date: new Date(),
|
||||
memo: 'This is my memo',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(EditCreationFormular, { localVue, mocks, propsData, data, apolloProvider })
|
||||
let mockMutate
|
||||
let mockOnDone
|
||||
let mockOnError
|
||||
const mockRefetch = vi.fn()
|
||||
const mockT = vi.fn((key) => key)
|
||||
const mockD = vi.fn((date) => new Date(date).toISOString().split('T')[0])
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockCreationMonths = {
|
||||
radioOptions: vi.fn(() => [
|
||||
{ item: { short: 'Jan', date: '2023-01-01' }, name: 'January' },
|
||||
{ item: { short: 'Feb', date: '2023-02-01' }, name: 'February' },
|
||||
{ item: { short: 'Mar', date: '2023-03-01' }, name: 'March' },
|
||||
]),
|
||||
creation: { value: [1000, 1000, 1000] },
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.vm.$nextTick()
|
||||
beforeEach(() => {
|
||||
mockMutate = vi.fn()
|
||||
mockOnDone = vi.fn()
|
||||
mockOnError = vi.fn()
|
||||
useMutation.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
onDone: mockOnDone,
|
||||
onError: mockOnError,
|
||||
})
|
||||
useQuery.mockReturnValue({ refetch: mockRefetch })
|
||||
useI18n.mockReturnValue({ t: mockT, d: mockD })
|
||||
useAppToast.mockReturnValue({ toastSuccess: mockToastSuccess, toastError: mockToastError })
|
||||
useCreationMonths.mockReturnValue(mockCreationMonths)
|
||||
|
||||
it('has a DIV element with the class.component-edit-creation-formular', () => {
|
||||
expect(wrapper.find('.component-edit-creation-formular').exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('radio buttons to select month', () => {
|
||||
it('has three radio buttons', () => {
|
||||
expect(wrapper.findAll('input[type="radio"]').length).toBe(3)
|
||||
})
|
||||
|
||||
it('has the third radio button checked', () => {
|
||||
expect(wrapper.findAll('input[type="radio"]').at(0).element.checked).toBeFalsy()
|
||||
expect(wrapper.findAll('input[type="radio"]').at(1).element.checked).toBeFalsy()
|
||||
expect(wrapper.findAll('input[type="radio"]').at(2).element.checked).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has rangeMax of 700', () => {
|
||||
expect(wrapper.find('input[type="number"]').attributes('max')).toBe('700')
|
||||
})
|
||||
|
||||
describe('change and save memo and value with success', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input[type="number"]').setValue(500)
|
||||
await wrapper.find('textarea').setValue('Test Schöpfung 2')
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
})
|
||||
|
||||
it('calls the API', () => {
|
||||
expect(adminUpdateContributionMock).toBeCalledWith({
|
||||
id: 0,
|
||||
creationDate: getCreationDate(0),
|
||||
amount: 500,
|
||||
memo: 'Test Schöpfung 2',
|
||||
})
|
||||
})
|
||||
|
||||
it('emits update-creation-data', () => {
|
||||
expect(wrapper.emitted('update-creation-data')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('change and save memo and value with error', () => {
|
||||
beforeEach(async () => {
|
||||
adminUpdateContributionMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('input[type="number"]').setValue(500)
|
||||
await wrapper.find('textarea').setValue('Test Schöpfung 2')
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
wrapper = mount(EditCreationFormular, {
|
||||
props: {
|
||||
item: {
|
||||
id: 1,
|
||||
contributionDate: '2023-02-15',
|
||||
amount: '300',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
creationUserData: {
|
||||
id: 1,
|
||||
memo: 'Initial memo',
|
||||
amount: 200,
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BForm,
|
||||
BRow,
|
||||
BCol,
|
||||
BButton,
|
||||
BFormRadioGroup,
|
||||
BInputGroup,
|
||||
BFormInput,
|
||||
BFormTextarea,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.component-edit-creation-formular').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes form with correct values', () => {
|
||||
expect(wrapper.vm.text).toBe('Initial memo')
|
||||
expect(wrapper.vm.value).toBe(200)
|
||||
expect(wrapper.vm.selected).toEqual({ short: 'Feb', date: '2023-02-01' })
|
||||
})
|
||||
|
||||
it('computes rangeMax correctly', () => {
|
||||
expect(wrapper.vm.rangeMax).toBe(1300) // 1000 + 300
|
||||
})
|
||||
|
||||
it('disables submit button when form is invalid', async () => {
|
||||
wrapper.vm.text = 'memo'
|
||||
const submitButton = wrapper.find('.test-submit')
|
||||
await nextTick()
|
||||
expect(submitButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables submit button when form is valid', async () => {
|
||||
wrapper.vm.text = 'Valid long text'
|
||||
wrapper.vm.value = 100
|
||||
const submitButton = wrapper.find('.test-submit')
|
||||
await nextTick()
|
||||
expect(submitButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls mutation on form submit', async () => {
|
||||
wrapper.vm.text = 'New memo valid'
|
||||
wrapper.vm.value = 250
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
creationDate: '2023-02-01',
|
||||
amount: 250,
|
||||
memo: 'New memo valid',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles successful mutation', async () => {
|
||||
wrapper.vm.text = 'New memo valid'
|
||||
wrapper.vm.value = 250
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
|
||||
// Simulate successful mutation
|
||||
const onDoneCallback = mockOnDone.mock.calls[0][0]
|
||||
onDoneCallback()
|
||||
|
||||
expect(wrapper.emitted('update-creation-data')).toBeTruthy()
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('creation_form.toasted_update')
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(wrapper.vm.value).toBe(0) // Check if form was reset
|
||||
})
|
||||
|
||||
it('handles failed mutation', async () => {
|
||||
wrapper.vm.text = 'New memo valid'
|
||||
wrapper.vm.value = 250
|
||||
await wrapper.find('.test-submit').trigger('click')
|
||||
|
||||
// Simulate failed mutation
|
||||
const onErrorCallback = mockOnError.mock.calls[0][0]
|
||||
onErrorCallback({ message: 'API Error' })
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('API Error')
|
||||
expect(wrapper.vm.value).toBe(0) // Check if form was reset
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,166 +1,162 @@
|
||||
<template>
|
||||
<div class="component-edit-creation-formular">
|
||||
<div class="shadow p-3 mb-5 bg-white rounded">
|
||||
<b-form ref="updateCreationForm">
|
||||
<div class="ml-4">
|
||||
<BForm ref="updateCreationForm">
|
||||
<div class="ms-4">
|
||||
<label>{{ $t('creation_form.select_month') }}</label>
|
||||
</div>
|
||||
<b-row class="m-4">
|
||||
<b-form-radio-group
|
||||
<BRow class="m-4">
|
||||
<BFormRadioGroup
|
||||
v-model="selected"
|
||||
:options="radioOptions"
|
||||
:options="creationMonths.radioOptions()"
|
||||
value-field="item"
|
||||
text-field="name"
|
||||
name="month-selection"
|
||||
:disabled="true"
|
||||
></b-form-radio-group>
|
||||
</b-row>
|
||||
></BFormRadioGroup>
|
||||
</BRow>
|
||||
<div class="m-4">
|
||||
<label>{{ $t('creation_form.select_value') }}</label>
|
||||
<div>
|
||||
<b-input-group prepend="GDD" append=".00">
|
||||
<b-form-input
|
||||
<BInputGroup prepend="GDD" append=".00">
|
||||
<BFormInput
|
||||
v-model="value"
|
||||
type="number"
|
||||
v-model="value"
|
||||
:min="rangeMin"
|
||||
:max="rangeMax"
|
||||
></b-form-input>
|
||||
</b-input-group>
|
||||
<b-input-group prepend="0" :append="String(rangeMax)" class="mt-3">
|
||||
<b-form-input
|
||||
type="range"
|
||||
v-model="value"
|
||||
:min="rangeMin"
|
||||
:max="rangeMax"
|
||||
step="10"
|
||||
></b-form-input>
|
||||
</b-input-group>
|
||||
></BFormInput>
|
||||
</BInputGroup>
|
||||
<BInputGroup
|
||||
prepend="0"
|
||||
:append="String(rangeMax)"
|
||||
class="mt-3 flex-nowrap align-items-center"
|
||||
>
|
||||
<BFormInput v-model="value" type="range" :min="rangeMin" :max="rangeMax" step="10" />
|
||||
</BInputGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-4">
|
||||
<label>{{ $t('creation_form.enter_text') }}</label>
|
||||
<div>
|
||||
<b-form-textarea
|
||||
<BFormTextarea
|
||||
id="textarea-state"
|
||||
v-model="text"
|
||||
:state="text.length >= 10"
|
||||
placeholder="Mindestens 10 Zeichen eingeben"
|
||||
rows="3"
|
||||
></b-form-textarea>
|
||||
></BFormTextarea>
|
||||
</div>
|
||||
</div>
|
||||
<b-row class="m-4">
|
||||
<b-col class="text-left">
|
||||
<b-button type="reset" variant="danger" @click="$refs.updateCreationForm.reset()">
|
||||
<BRow class="m-4">
|
||||
<BCol class="text-start">
|
||||
<BButton type="reset" variant="danger" @click="$refs.updateCreationForm.reset()">
|
||||
{{ $t('creation_form.reset') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-center">
|
||||
<div class="text-right">
|
||||
<b-button
|
||||
</BButton>
|
||||
</BCol>
|
||||
<BCol class="text-center">
|
||||
<div class="text-end">
|
||||
<BButton
|
||||
type="button"
|
||||
variant="success"
|
||||
class="test-submit"
|
||||
:disabled="submitDisabled"
|
||||
@click="submitCreation"
|
||||
:disabled="selected === '' || value <= 0 || text.length < 10"
|
||||
>
|
||||
{{ $t('creation_form.update_creation') }}
|
||||
</b-button>
|
||||
</BButton>
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-form>
|
||||
</BCol>
|
||||
</BRow>
|
||||
</BForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { adminUpdateContribution } from '../graphql/adminUpdateContribution'
|
||||
import { creationMonths } from '../mixins/creationMonths'
|
||||
|
||||
export default {
|
||||
name: 'EditCreationFormular',
|
||||
mixins: [creationMonths],
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
row: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
creationUserData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { adminUpdateContribution } from '../graphql/adminUpdateContribution'
|
||||
import { adminOpenCreations } from '../graphql/adminOpenCreations'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import useCreationMonths from '@/composables/useCreationMonths'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: !this.creationUserData.memo ? '' : this.creationUserData.memo,
|
||||
value: !this.creationUserData.amount ? 0 : Number(this.creationUserData.amount),
|
||||
rangeMin: 0,
|
||||
selected: this.selectedComputed,
|
||||
userId: this.item.userId,
|
||||
}
|
||||
row: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
methods: {
|
||||
submitCreation() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: adminUpdateContribution,
|
||||
variables: {
|
||||
id: this.item.id,
|
||||
creationDate: this.selected.date,
|
||||
amount: Number(this.value),
|
||||
memo: this.text,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.$emit('update-creation-data')
|
||||
this.toastSuccess(
|
||||
this.$t('creation_form.toasted_update', {
|
||||
value: this.value,
|
||||
email: this.item.email,
|
||||
}),
|
||||
)
|
||||
// das creation Formular reseten
|
||||
this.$refs.updateCreationForm.reset()
|
||||
// Den geschöpften Wert auf o setzen
|
||||
this.value = 0
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
// das creation Formular reseten
|
||||
this.$refs.updateCreationForm.reset()
|
||||
// Den geschöpften Wert auf o setzen
|
||||
this.value = 0
|
||||
})
|
||||
.finally(() => {
|
||||
this.$apollo.queries.OpenCreations.refetch()
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
creationIndex() {
|
||||
const month = this.$d(new Date(this.item.contributionDate), 'month')
|
||||
return this.radioOptions.findIndex((obj) => {
|
||||
return obj.item.short === month
|
||||
})
|
||||
},
|
||||
selectedComputed() {
|
||||
return this.radioOptions[this.creationIndex].item
|
||||
},
|
||||
rangeMax() {
|
||||
return Number(this.creation[this.creationIndex]) + Number(this.item.amount)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedComputed() {
|
||||
this.selected = this.selectedComputed
|
||||
},
|
||||
creationUserData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-creation-data'])
|
||||
const creationMonths = useCreationMonths()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { toastSuccess, toastError } = useAppToast()
|
||||
const text = ref(props.creationUserData.memo || '')
|
||||
const value = ref(props.creationUserData.amount ? Number(props.creationUserData.amount) : 0)
|
||||
const rangeMin = ref(0)
|
||||
|
||||
const creationIndex = computed(() => {
|
||||
const date = new Date(props.item.contributionDate)
|
||||
const month = date.toLocaleString('default', { month: 'short' })
|
||||
return creationMonths.radioOptions().findIndex((obj) => obj.item.short === month)
|
||||
})
|
||||
|
||||
const selectedComputed = computed(() => {
|
||||
const index = creationIndex.value > -1 ? creationIndex.value : 0
|
||||
return creationMonths.radioOptions()[index].item
|
||||
})
|
||||
const selected = ref(selectedComputed.value)
|
||||
const rangeMax = computed(
|
||||
() => Number(creationMonths.creation.value[creationIndex.value]) + Number(props.item.amount),
|
||||
)
|
||||
|
||||
const submitDisabled = computed(() => {
|
||||
return selected.value === '' || value.value <= 0 || text.value.length < 10
|
||||
})
|
||||
|
||||
watch(selectedComputed, () => {
|
||||
selected.value = selectedComputed.value
|
||||
})
|
||||
|
||||
const { mutate: updateMutation, onDone, onError } = useMutation(adminUpdateContribution)
|
||||
const { refetch: refetchCreations } = useQuery(adminOpenCreations, {
|
||||
userId: props.creationUserData.id,
|
||||
})
|
||||
|
||||
onDone(() => {
|
||||
emit('update-creation-data')
|
||||
toastSuccess(t('creation_form.toasted_update', { value: value.value, email: props.item.email }))
|
||||
resetForm()
|
||||
refetchCreations()
|
||||
})
|
||||
|
||||
onError((error) => {
|
||||
toastError(error.message)
|
||||
resetForm()
|
||||
})
|
||||
|
||||
const submitCreation = () => {
|
||||
updateMutation({
|
||||
id: props.item.id,
|
||||
creationDate: selected.value.date,
|
||||
amount: Number(value.value),
|
||||
memo: text.value,
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
value.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,183 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FederationVisualizeItem from './FederationVisualizeItem.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
const today = new Date()
|
||||
const createdDate = new Date()
|
||||
createdDate.setDate(createdDate.getDate() - 3)
|
||||
|
||||
let propsData = {
|
||||
item: {
|
||||
id: 7590,
|
||||
foreign: false,
|
||||
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
|
||||
url: 'http://localhost/api/2_0',
|
||||
lastAnnouncedAt: createdDate,
|
||||
verifiedAt: today,
|
||||
lastErrorAt: null,
|
||||
createdAt: createdDate,
|
||||
updatedAt: null,
|
||||
},
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$i18n: {
|
||||
locale: 'en',
|
||||
},
|
||||
}
|
||||
|
||||
describe('FederationVisualizeItem', () => {
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(FederationVisualizeItem, { localVue, mocks, propsData })
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('div.federation-visualize-item').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('rendering item properties', () => {
|
||||
it('has the url', () => {
|
||||
expect(wrapper.find('.row > div:nth-child(2) > div').text()).toBe(
|
||||
'http://localhost/api/2_0',
|
||||
)
|
||||
})
|
||||
|
||||
it('has the public key', () => {
|
||||
expect(wrapper.find('.row > div:nth-child(2) > small').text()).toContain(
|
||||
'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7'.substring(0, 26),
|
||||
)
|
||||
})
|
||||
|
||||
describe('verified item', () => {
|
||||
it('has the check icon', () => {
|
||||
expect(wrapper.find('svg.bi-check').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has the text variant "success"', () => {
|
||||
expect(wrapper.find('.text-success').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('not verified item', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
item: {
|
||||
id: 7590,
|
||||
foreign: false,
|
||||
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
|
||||
url: 'http://localhost/api/2_0',
|
||||
lastAnnouncedAt: createdDate,
|
||||
verifiedAt: null,
|
||||
lastErrorAt: null,
|
||||
createdAt: createdDate,
|
||||
updatedAt: null,
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has the x-circle icon', () => {
|
||||
expect(wrapper.find('svg.bi-x-circle').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has the text variant "danger"', () => {
|
||||
expect(wrapper.find('.text-danger').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// describe('with different locales (de, en, fr, es, nl)', () => {
|
||||
describe('lastAnnouncedAt', () => {
|
||||
it('computes the time string for different locales (de, en, fr, es, nl)', () => {
|
||||
wrapper.vm.$i18n.locale = 'de'
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.lastAnnouncedAt).toBe('vor 3 Tagen')
|
||||
|
||||
wrapper.vm.$i18n.locale = 'fr'
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.lastAnnouncedAt).toBe('il y a 3 jours')
|
||||
|
||||
wrapper.vm.$i18n.locale = 'es'
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.lastAnnouncedAt).toBe('hace 3 días')
|
||||
|
||||
wrapper.vm.$i18n.locale = 'nl'
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.lastAnnouncedAt).toBe('3 dagen geleden')
|
||||
})
|
||||
|
||||
describe('lastAnnouncedAt == null', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
item: {
|
||||
id: 7590,
|
||||
foreign: false,
|
||||
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
|
||||
url: 'http://localhost/api/2_0',
|
||||
lastAnnouncedAt: null,
|
||||
verifiedAt: null,
|
||||
lastErrorAt: null,
|
||||
createdAt: createdDate,
|
||||
updatedAt: null,
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('computes empty string', async () => {
|
||||
expect(wrapper.vm.lastAnnouncedAt).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createdAt', () => {
|
||||
it('computes the time string for different locales (de, en, fr, es, nl)', () => {
|
||||
wrapper.vm.$i18n.locale = 'de'
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.createdAt).toBe('vor 3 Tagen')
|
||||
|
||||
wrapper.vm.$i18n.locale = 'fr'
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.createdAt).toBe('il y a 3 jours')
|
||||
|
||||
wrapper.vm.$i18n.locale = 'es'
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.createdAt).toBe('hace 3 días')
|
||||
|
||||
wrapper.vm.$i18n.locale = 'nl'
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.createdAt).toBe('3 dagen geleden')
|
||||
})
|
||||
|
||||
describe('createdAt == null', () => {
|
||||
beforeEach(() => {
|
||||
propsData = {
|
||||
item: {
|
||||
id: 7590,
|
||||
foreign: false,
|
||||
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
|
||||
url: 'http://localhost/api/2_0',
|
||||
lastAnnouncedAt: createdDate,
|
||||
verifiedAt: null,
|
||||
lastErrorAt: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('computes empty string', async () => {
|
||||
expect(wrapper.vm.createdAt).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<div class="federation-visualize-item">
|
||||
<b-row>
|
||||
<b-col cols="1"><b-icon :icon="icon" :variant="variant" class="mr-4"></b-icon></b-col>
|
||||
<b-col>
|
||||
<div>{{ item.url }}</div>
|
||||
<small>{{ `${item.publicKey.substring(0, 26)}…` }}</small>
|
||||
</b-col>
|
||||
<b-col cols="2">{{ lastAnnouncedAt }}</b-col>
|
||||
<b-col cols="2">{{ createdAt }}</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
|
||||
|
||||
const locales = { en, de, es, fr, nl }
|
||||
|
||||
export default {
|
||||
name: 'FederationVisualizeItem',
|
||||
props: {
|
||||
item: { type: Object },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
formatDistanceToNow,
|
||||
locale: this.$i18n.locale,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
verified() {
|
||||
return new Date(this.item.verifiedAt) >= new Date(this.item.lastAnnouncedAt)
|
||||
},
|
||||
icon() {
|
||||
return this.verified ? 'check' : 'x-circle'
|
||||
},
|
||||
variant() {
|
||||
return this.verified ? 'success' : 'danger'
|
||||
},
|
||||
lastAnnouncedAt() {
|
||||
if (this.item.lastAnnouncedAt) {
|
||||
return formatDistanceToNow(new Date(this.item.lastAnnouncedAt), {
|
||||
includeSecond: true,
|
||||
addSuffix: true,
|
||||
locale: locales[this.locale],
|
||||
})
|
||||
}
|
||||
return ''
|
||||
},
|
||||
createdAt() {
|
||||
if (this.item.createdAt) {
|
||||
return formatDistanceToNow(new Date(this.item.createdAt), {
|
||||
includeSecond: true,
|
||||
addSuffix: true,
|
||||
locale: locales[this.locale],
|
||||
})
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
660
admin/src/components/Federation/CommunityVisualizeItem.spec.js
Normal file
@ -0,0 +1,660 @@
|
||||
// import { createMockClient } from 'mock-apollo-client'
|
||||
// import { mount } from '@vue/test-utils'
|
||||
// import VueApollo from 'vue-apollo'
|
||||
// import Vuex from 'vuex'
|
||||
// import CommunityVisualizeItem from './CommunityVisualizeItem.vue'
|
||||
// import { updateHomeCommunity } from '../../graphql/updateHomeCommunity'
|
||||
// import { toastSuccessSpy } from '../../../test/testSetup'
|
||||
//
|
||||
// const mockClient = createMockClient()
|
||||
// const apolloProvider = new VueApollo({
|
||||
// defaultClient: mockClient,
|
||||
// })
|
||||
//
|
||||
// const localVue = global.localVue
|
||||
// localVue.use(Vuex)
|
||||
// localVue.use(VueApollo)
|
||||
// const today = new Date()
|
||||
// const createdDate = new Date()
|
||||
// createdDate.setDate(createdDate.getDate() - 3)
|
||||
//
|
||||
// // Mock für den Vuex-Store
|
||||
// const store = new Vuex.Store({
|
||||
// state: {
|
||||
// moderator: {
|
||||
// roles: ['ADMIN'],
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// let propsData = {
|
||||
// item: {
|
||||
// uuid: 1,
|
||||
// foreign: false,
|
||||
// url: 'http://localhost/api/',
|
||||
// publicKey: '4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2',
|
||||
// communityUuid: '5ab0befd-b150-4f31-a631-7f3637e47b21',
|
||||
// authenticatedAt: null,
|
||||
// name: 'Gradido Test',
|
||||
// description: 'Gradido Community zum testen',
|
||||
// gmsApiKey: '<api key>',
|
||||
// creationDate: createdDate,
|
||||
// createdAt: createdDate,
|
||||
// updatedAt: createdDate,
|
||||
// federatedCommunities: [
|
||||
// {
|
||||
// id: 2046,
|
||||
// apiVersion: '2_0',
|
||||
// endPoint: 'http://localhost/api/',
|
||||
// lastAnnouncedAt: createdDate,
|
||||
// verifiedAt: today,
|
||||
// lastErrorAt: null,
|
||||
// createdAt: createdDate,
|
||||
// updatedAt: null,
|
||||
// },
|
||||
// {
|
||||
// id: 2045,
|
||||
// apiVersion: '1_1',
|
||||
// endPoint: 'http://localhost/api/',
|
||||
// lastAnnouncedAt: null,
|
||||
// verifiedAt: null,
|
||||
// lastErrorAt: null,
|
||||
// createdAt: '2024-01-16T10:08:21.550Z',
|
||||
// updatedAt: null,
|
||||
// },
|
||||
// {
|
||||
// id: 2044,
|
||||
// apiVersion: '1_0',
|
||||
// endPoint: 'http://localhost/api/',
|
||||
// lastAnnouncedAt: null,
|
||||
// verifiedAt: null,
|
||||
// lastErrorAt: null,
|
||||
// createdAt: '2024-01-16T10:08:21.544Z',
|
||||
// updatedAt: null,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// const mocks = {
|
||||
// $t: (key) => key,
|
||||
// $i18n: {
|
||||
// locale: 'en',
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// describe('CommunityVisualizeItem', () => {
|
||||
// let wrapper
|
||||
//
|
||||
// const updateHomeCommunityMock = jest.fn()
|
||||
// mockClient.setRequestHandler(
|
||||
// updateHomeCommunity,
|
||||
// updateHomeCommunityMock.mockResolvedValue({
|
||||
// data: {
|
||||
// updateHomeCommunity: { id: 1 },
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
//
|
||||
// const Wrapper = () => {
|
||||
// return mount(CommunityVisualizeItem, { localVue, mocks, propsData, store, apolloProvider })
|
||||
// }
|
||||
//
|
||||
// describe('mount', () => {
|
||||
// beforeEach(() => {
|
||||
// wrapper = Wrapper()
|
||||
// })
|
||||
//
|
||||
// it('renders the component', () => {
|
||||
// expect(wrapper.exists()).toBe(true)
|
||||
// expect(wrapper.find('div.community-visualize-item').exists()).toBe(true)
|
||||
// expect(wrapper.find('.details').exists()).toBe(false)
|
||||
// })
|
||||
//
|
||||
// it('toggles details on click', async () => {
|
||||
// // Click the row to toggle details
|
||||
// await wrapper.find('.row').trigger('click')
|
||||
//
|
||||
// // Assert that details are now open
|
||||
// expect(wrapper.find('.details').exists()).toBe(true)
|
||||
//
|
||||
// // Click the row again to toggle details back
|
||||
// await wrapper.find('.row').trigger('click')
|
||||
//
|
||||
// // Assert that details are now closed
|
||||
// expect(wrapper.find('.details').exists()).toBe(false)
|
||||
// })
|
||||
//
|
||||
// describe('rendering item properties', () => {
|
||||
// it('has the url', () => {
|
||||
// expect(wrapper.find('.row > div:nth-child(2) > div > a').text()).toBe(
|
||||
// 'http://localhost/api/',
|
||||
// )
|
||||
// })
|
||||
//
|
||||
// it('has the public key', () => {
|
||||
// expect(wrapper.find('.row > div:nth-child(2) > small').text()).toContain(
|
||||
// '4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2'.substring(0, 26),
|
||||
// )
|
||||
// })
|
||||
//
|
||||
// describe('verified item', () => {
|
||||
// it('has the check icon', () => {
|
||||
// expect(wrapper.find('svg.bi-check').exists()).toBe(true)
|
||||
// })
|
||||
//
|
||||
// it('has the text variant "success"', () => {
|
||||
// expect(wrapper.find('.text-success').exists()).toBe(true)
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// // describe('with different locales (de, en, fr, es, nl)', () => {
|
||||
// describe('lastAnnouncedAt', () => {
|
||||
// it('computes the time string for different locales (de, en, fr, es, nl)', () => {
|
||||
// wrapper.vm.$i18n.locale = 'de'
|
||||
// wrapper = Wrapper()
|
||||
// expect(wrapper.vm.lastAnnouncedAt).toBe('vor 3 Tagen')
|
||||
//
|
||||
// wrapper.vm.$i18n.locale = 'fr'
|
||||
// wrapper = Wrapper()
|
||||
// expect(wrapper.vm.lastAnnouncedAt).toBe('il y a 3 jours')
|
||||
//
|
||||
// wrapper.vm.$i18n.locale = 'es'
|
||||
// wrapper = Wrapper()
|
||||
// expect(wrapper.vm.lastAnnouncedAt).toBe('hace 3 días')
|
||||
//
|
||||
// wrapper.vm.$i18n.locale = 'nl'
|
||||
// wrapper = Wrapper()
|
||||
// expect(wrapper.vm.lastAnnouncedAt).toBe('3 dagen geleden')
|
||||
// })
|
||||
//
|
||||
// describe('lastAnnouncedAt == null', () => {
|
||||
// beforeEach(() => {
|
||||
// propsData = {
|
||||
// item: {
|
||||
// uuid: 7590,
|
||||
// foreign: false,
|
||||
// publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
|
||||
// url: 'http://localhost/api/2_0',
|
||||
// lastAnnouncedAt: null,
|
||||
// verifiedAt: null,
|
||||
// lastErrorAt: null,
|
||||
// createdAt: createdDate,
|
||||
// updatedAt: null,
|
||||
// },
|
||||
// }
|
||||
// wrapper = Wrapper()
|
||||
// })
|
||||
//
|
||||
// it('computes empty string', async () => {
|
||||
// expect(wrapper.vm.lastAnnouncedAt).toBe('')
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// describe('createdAt', () => {
|
||||
// it('computes the time string for different locales (de, en, fr, es, nl)', () => {
|
||||
// wrapper.vm.$i18n.locale = 'de'
|
||||
// wrapper = Wrapper()
|
||||
// expect(wrapper.vm.createdAt).toBe('vor 3 Tagen')
|
||||
//
|
||||
// wrapper.vm.$i18n.locale = 'fr'
|
||||
// wrapper = Wrapper()
|
||||
// expect(wrapper.vm.createdAt).toBe('il y a 3 jours')
|
||||
//
|
||||
// wrapper.vm.$i18n.locale = 'es'
|
||||
// wrapper = Wrapper()
|
||||
// expect(wrapper.vm.createdAt).toBe('hace 3 días')
|
||||
//
|
||||
// wrapper.vm.$i18n.locale = 'nl'
|
||||
// wrapper = Wrapper()
|
||||
// expect(wrapper.vm.createdAt).toBe('3 dagen geleden')
|
||||
// })
|
||||
//
|
||||
// describe('not verified item', () => {
|
||||
// beforeEach(() => {
|
||||
// propsData = {
|
||||
// item: {
|
||||
// uuid: 7590,
|
||||
// foreign: false,
|
||||
// publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
|
||||
// url: 'http://localhost/api/',
|
||||
// createdAt: createdDate,
|
||||
// updatedAt: null,
|
||||
// },
|
||||
// }
|
||||
// wrapper = Wrapper()
|
||||
// })
|
||||
//
|
||||
// it('has the x-circle icon', () => {
|
||||
// expect(wrapper.find('svg.bi-x-circle').exists()).toBe(true)
|
||||
// })
|
||||
//
|
||||
// it('has the text variant "danger"', () => {
|
||||
// expect(wrapper.find('.text-danger').exists()).toBe(true)
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// describe('createdAt == null', () => {
|
||||
// beforeEach(() => {
|
||||
// propsData = {
|
||||
// item: {
|
||||
// uuid: 7590,
|
||||
// foreign: false,
|
||||
// publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
|
||||
// url: 'http://localhost/api/2_0',
|
||||
// communityUuid: '5ab0befd-b150-4f31-a631-7f3637e47b21',
|
||||
// authenticatedAt: null,
|
||||
// creationDate: null,
|
||||
// createdAt: null,
|
||||
// updatedAt: null,
|
||||
// },
|
||||
// }
|
||||
// wrapper = Wrapper()
|
||||
// })
|
||||
//
|
||||
// it('computes empty string', async () => {
|
||||
// expect(wrapper.vm.createdAt).toBe('')
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// describe('test handleUpdateHomeCommunity', () => {
|
||||
// describe('gms api key', () => {
|
||||
// beforeEach(async () => {
|
||||
// wrapper = Wrapper()
|
||||
// wrapper.vm.originalGmsApiKey = 'original'
|
||||
// wrapper.vm.gmsApiKey = 'changed key'
|
||||
//
|
||||
// await wrapper.vm.handleUpdateHomeCommunity()
|
||||
// // Wait for the next tick to allow async operations to complete
|
||||
// await wrapper.vm.$nextTick()
|
||||
// })
|
||||
//
|
||||
// it('expect changed gms api key', () => {
|
||||
// expect(updateHomeCommunityMock).toBeCalledWith({
|
||||
// uuid: propsData.item.uuid,
|
||||
// gmsApiKey: 'changed key',
|
||||
// location: undefined,
|
||||
// })
|
||||
// expect(wrapper.vm.originalGmsApiKey).toBe('changed key')
|
||||
// expect(toastSuccessSpy).toBeCalledWith('federation.toast_gmsApiKeyUpdated')
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// describe('location', () => {
|
||||
// beforeEach(async () => {
|
||||
// wrapper = Wrapper()
|
||||
// wrapper.vm.originalLocation = { latitude: 15.121, longitude: 1.212 }
|
||||
// wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
|
||||
//
|
||||
// await wrapper.vm.handleUpdateHomeCommunity()
|
||||
// // Wait for the next tick to allow async operations to complete
|
||||
// await wrapper.vm.$nextTick()
|
||||
// })
|
||||
//
|
||||
// it('expect changed location', () => {
|
||||
// expect(updateHomeCommunityMock).toBeCalledWith({
|
||||
// uuid: propsData.item.uuid,
|
||||
// location: { latitude: 1.121, longitude: 17.212 },
|
||||
// gmsApiKey: undefined,
|
||||
// })
|
||||
// expect(wrapper.vm.originalLocation).toStrictEqual({
|
||||
// latitude: 1.121,
|
||||
// longitude: 17.212,
|
||||
// })
|
||||
// expect(toastSuccessSpy).toBeCalledWith('federation.toast_gmsLocationUpdated')
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// describe('gms api key and location', () => {
|
||||
// beforeEach(async () => {
|
||||
// wrapper = Wrapper()
|
||||
// wrapper.vm.originalGmsApiKey = 'original'
|
||||
// wrapper.vm.gmsApiKey = 'changed key'
|
||||
// wrapper.vm.originalLocation = { latitude: 15.121, longitude: 1.212 }
|
||||
// wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
|
||||
//
|
||||
// await wrapper.vm.handleUpdateHomeCommunity()
|
||||
// // Wait for the next tick to allow async operations to complete
|
||||
// await wrapper.vm.$nextTick()
|
||||
// })
|
||||
//
|
||||
// it('expect changed gms api key and changed location', () => {
|
||||
// expect(updateHomeCommunityMock).toBeCalledWith({
|
||||
// uuid: propsData.item.uuid,
|
||||
// gmsApiKey: 'changed key',
|
||||
// location: undefined,
|
||||
// })
|
||||
// expect(wrapper.vm.originalGmsApiKey).toBe('changed key')
|
||||
// expect(wrapper.vm.originalLocation).toStrictEqual({
|
||||
// latitude: 1.121,
|
||||
// longitude: 17.212,
|
||||
// })
|
||||
// expect(toastSuccessSpy).toBeCalledWith('federation.toast_gmsApiKeyAndLocationUpdated')
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// describe('test resetHomeCommunityEditable', () => {
|
||||
// beforeEach(async () => {
|
||||
// wrapper = Wrapper()
|
||||
// })
|
||||
//
|
||||
// it('test', () => {
|
||||
// wrapper.vm.originalGmsApiKey = 'original'
|
||||
// wrapper.vm.gmsApiKey = 'changed key'
|
||||
// wrapper.vm.originalLocation = { latitude: 15.121, longitude: 1.212 }
|
||||
// wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
|
||||
// wrapper.vm.resetHomeCommunityEditable()
|
||||
//
|
||||
// expect(wrapper.vm.location).toStrictEqual({ latitude: 15.121, longitude: 1.212 })
|
||||
// expect(wrapper.vm.gmsApiKey).toBe('original')
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createStore } from 'vuex'
|
||||
import CommunityVisualizeItem from './CommunityVisualizeItem.vue'
|
||||
import { BCol, BFormGroup, BListGroup, BListGroupItem, BRow } from 'bootstrap-vue-next'
|
||||
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useAppToast: vi.fn(() => ({
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
locale: ref('en'),
|
||||
t: (key) => key,
|
||||
})),
|
||||
}))
|
||||
|
||||
const mockMutate = vi.fn().mockResolvedValue({ data: { updateHomeCommunity: { id: 1 } } })
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: mockMutate,
|
||||
})),
|
||||
}))
|
||||
|
||||
const today = new Date()
|
||||
const createdDate = new Date(today)
|
||||
createdDate.setDate(createdDate.getDate() - 3)
|
||||
|
||||
const createItem = (overrides = {}) => ({
|
||||
uuid: 1,
|
||||
foreign: false,
|
||||
url: 'http://localhost/api/',
|
||||
publicKey: '4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2',
|
||||
communityUuid: '5ab0befd-b150-4f31-a631-7f3637e47b21',
|
||||
authenticatedAt: null,
|
||||
name: 'Gradido Test',
|
||||
description: 'Gradido Community zum testen',
|
||||
gmsApiKey: '<api key>',
|
||||
creationDate: createdDate,
|
||||
createdAt: createdDate,
|
||||
updatedAt: createdDate,
|
||||
federatedCommunities: [
|
||||
{
|
||||
id: 2046,
|
||||
apiVersion: '2_0',
|
||||
endPoint: 'http://localhost/api/',
|
||||
lastAnnouncedAt: createdDate,
|
||||
verifiedAt: today,
|
||||
lastErrorAt: null,
|
||||
createdAt: createdDate,
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
id: 2045,
|
||||
apiVersion: '1_1',
|
||||
endPoint: 'http://localhost/api/',
|
||||
lastAnnouncedAt: null,
|
||||
verifiedAt: null,
|
||||
lastErrorAt: null,
|
||||
createdAt: '2024-01-16T10:08:21.550Z',
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
id: 2044,
|
||||
apiVersion: '1_0',
|
||||
endPoint: 'http://localhost/api/',
|
||||
lastAnnouncedAt: null,
|
||||
verifiedAt: null,
|
||||
lastErrorAt: null,
|
||||
createdAt: '2024-01-16T10:08:21.544Z',
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('CommunityVisualizeItem', () => {
|
||||
let wrapper
|
||||
let store
|
||||
let mockI18n
|
||||
|
||||
const createWrapper = (props = {}, locale = 'en') => {
|
||||
store = createStore({
|
||||
state: {
|
||||
moderator: {
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mockI18n = {
|
||||
locale: ref(locale),
|
||||
t: (key) => key,
|
||||
}
|
||||
vi.mocked(useI18n).mockReturnValue(mockI18n)
|
||||
|
||||
return mount(CommunityVisualizeItem, {
|
||||
props: {
|
||||
item: createItem(),
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
plugins: [store],
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$i18n: {
|
||||
locale: locale,
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
BRow,
|
||||
BCol,
|
||||
BListGroup,
|
||||
BListGroupItem,
|
||||
BFormGroup,
|
||||
'variant-icon': true,
|
||||
'editable-group': true,
|
||||
'editable-groupable-label': true,
|
||||
coordinates: true,
|
||||
'federation-visualize-item': true,
|
||||
},
|
||||
directives: {
|
||||
'b-tooltip': {},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('div.community-visualize-item').exists()).toBe(true)
|
||||
expect(wrapper.find('.details').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles details on click', async () => {
|
||||
await wrapper.find('.row').trigger('click')
|
||||
expect(wrapper.find('.details').exists()).toBe(true)
|
||||
|
||||
await wrapper.find('.row').trigger('click')
|
||||
expect(wrapper.find('.details').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('rendering item properties', () => {
|
||||
it('has the url', () => {
|
||||
expect(wrapper.find('.row > div:nth-child(2) > div > a').text()).toBe('http://localhost/api/')
|
||||
})
|
||||
|
||||
it('has the public key', () => {
|
||||
expect(wrapper.find('.row > div:nth-child(2) > small').text()).toContain(
|
||||
'4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2'.substring(0, 26),
|
||||
)
|
||||
})
|
||||
|
||||
describe('verified item', () => {
|
||||
it('has the check icon', () => {
|
||||
expect(wrapper.find('variant-icon-stub[icon="check"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has the text variant "success"', () => {
|
||||
expect(wrapper.find('variant-icon-stub[variant="success"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastAnnouncedAt', () => {
|
||||
it.each([
|
||||
['en', en],
|
||||
['de', de],
|
||||
['fr', fr],
|
||||
['es', es],
|
||||
['nl', nl],
|
||||
])('computes the time string for %s locale', async (locale, dateLocale) => {
|
||||
wrapper = createWrapper(
|
||||
{
|
||||
item: createItem(),
|
||||
},
|
||||
locale,
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
const expectedString = formatDistanceToNow(createdDate, {
|
||||
addSuffix: true,
|
||||
locale: dateLocale,
|
||||
})
|
||||
expect(wrapper.vm.lastAnnouncedAt).toBe(expectedString)
|
||||
})
|
||||
|
||||
it('computes empty string when lastAnnouncedAt is null', () => {
|
||||
wrapper = createWrapper({ item: createItem({ federatedCommunities: [] }) })
|
||||
expect(wrapper.vm.lastAnnouncedAt).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createdAt', () => {
|
||||
it.each([
|
||||
['en', en],
|
||||
['de', de],
|
||||
['fr', fr],
|
||||
['es', es],
|
||||
['nl', nl],
|
||||
])('computes the time string for %s locale', async (locale, dateLocale) => {
|
||||
wrapper = createWrapper(
|
||||
{
|
||||
item: createItem(),
|
||||
},
|
||||
locale,
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
const expectedString = formatDistanceToNow(createdDate, {
|
||||
addSuffix: true,
|
||||
locale: dateLocale,
|
||||
})
|
||||
expect(wrapper.vm.createdAt).toBe(expectedString)
|
||||
})
|
||||
|
||||
it('computes empty string when createdAt is null', () => {
|
||||
wrapper = createWrapper({ item: createItem({ createdAt: null }) })
|
||||
expect(wrapper.vm.createdAt).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('not verified item', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper({
|
||||
item: createItem({ federatedCommunities: [] }),
|
||||
})
|
||||
})
|
||||
|
||||
it('has the x-circle icon', () => {
|
||||
expect(wrapper.find('variant-icon-stub[icon="x-circle"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has the text variant "danger"', () => {
|
||||
expect(wrapper.find('variant-icon-stub[variant="danger"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUpdateHomeCommunity', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('updates gms api key', async () => {
|
||||
wrapper.vm.gmsApiKey = 'changed key'
|
||||
await wrapper.vm.handleUpdateHomeCommunity()
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
uuid: 1,
|
||||
gmsApiKey: 'changed key',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates location', async () => {
|
||||
wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
|
||||
await wrapper.vm.handleUpdateHomeCommunity()
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
uuid: 1,
|
||||
location: { latitude: 1.121, longitude: 17.212 },
|
||||
gmsApiKey: '<api key>',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates both gms api key and location', async () => {
|
||||
wrapper.vm.gmsApiKey = 'changed key'
|
||||
wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
|
||||
await wrapper.vm.handleUpdateHomeCommunity()
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
uuid: 1,
|
||||
gmsApiKey: 'changed key',
|
||||
location: { latitude: 1.121, longitude: 17.212 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetHomeCommunityEditable', () => {
|
||||
it('resets gms api key and location', () => {
|
||||
wrapper.vm.gmsApiKey = 'changed key'
|
||||
wrapper.vm.location = { latitude: 1.121, longitude: 17.212 }
|
||||
wrapper.vm.resetHomeCommunityEditable()
|
||||
|
||||
expect(wrapper.vm.gmsApiKey).toBe('<api key>')
|
||||
expect(wrapper.vm.location).toEqual(wrapper.vm.originalLocation)
|
||||
})
|
||||
})
|
||||
})
|
||||
201
admin/src/components/Federation/CommunityVisualizeItem.vue
Normal file
@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="community-visualize-item">
|
||||
<BRow v-on="{ click: toggleDetails }">
|
||||
<BCol cols="1">
|
||||
<variant-icon :icon="icon" :variant="variant" />
|
||||
</BCol>
|
||||
<BCol>
|
||||
<div>
|
||||
<a :href="item.url" target="_blank">{{ item.url }}</a>
|
||||
</div>
|
||||
<small>{{ `${item.publicKey.substring(0, 26)}…` }}</small>
|
||||
</BCol>
|
||||
<BCol>
|
||||
<span v-b-tooltip="`${item.description}`">{{ item.name }}</span>
|
||||
</BCol>
|
||||
<BCol cols="2">{{ lastAnnouncedAt }}</BCol>
|
||||
<BCol cols="2">{{ createdAt }}</BCol>
|
||||
</BRow>
|
||||
<BRow v-if="details" class="details">
|
||||
<BCol colspan="5">
|
||||
<BListGroup>
|
||||
<BListGroupItem v-if="item.uuid">
|
||||
{{ $t('federation.communityUuid') }} {{ item.uuid }}
|
||||
</BListGroupItem>
|
||||
<BListGroupItem v-if="item.authenticatedAt">
|
||||
{{ $t('federation.authenticatedAt') }} {{ item.authenticatedAt }}
|
||||
</BListGroupItem>
|
||||
<BListGroupItem>
|
||||
{{ $t('federation.publicKey') }} {{ item.publicKey }}
|
||||
</BListGroupItem>
|
||||
<BListGroupItem v-if="!item.foreign">
|
||||
<editable-group
|
||||
:allow-edit="$store.state.moderator.roles.includes('ADMIN')"
|
||||
@save="handleUpdateHomeCommunity"
|
||||
@reset="resetHomeCommunityEditable"
|
||||
>
|
||||
<template #view>
|
||||
<div class="d-flex">
|
||||
<p style="text-wrap: nowrap">{{ $t('federation.gmsApiKey') }} </p>
|
||||
<span class="d-block" style="overflow-x: auto">{{ gmsApiKey }}</span>
|
||||
</div>
|
||||
<BFormGroup>
|
||||
{{ $t('federation.coordinates') }}
|
||||
<span v-if="isValidLocation">
|
||||
{{
|
||||
$t('geo-coordinates.format', {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</BFormGroup>
|
||||
</template>
|
||||
<template #edit>
|
||||
<editable-groupable-label
|
||||
v-model="gmsApiKey"
|
||||
:label="$t('federation.gmsApiKey')"
|
||||
id-name="home-community-api-key"
|
||||
/>
|
||||
<coordinates v-model="location" />
|
||||
</template>
|
||||
</editable-group>
|
||||
</BListGroupItem>
|
||||
<BListGroup-item>
|
||||
<BListGroup>
|
||||
<BRow>
|
||||
<BCol class="ms-1">{{ $t('federation.verified') }}</BCol>
|
||||
<BCol>{{ $t('federation.apiVersion') }}</BCol>
|
||||
<BCol>{{ $t('federation.createdAt') }}</BCol>
|
||||
<BCol>{{ $t('federation.lastAnnouncedAt') }}</BCol>
|
||||
<BCol>{{ $t('federation.verifiedAt') }}</BCol>
|
||||
<BCol>{{ $t('federation.lastErrorAt') }}</BCol>
|
||||
</BRow>
|
||||
<BListGroup-item
|
||||
v-for="federation in item.federatedCommunities"
|
||||
:key="federation.id"
|
||||
:variant="!item.foreign ? 'primary' : 'warning'"
|
||||
>
|
||||
<federation-visualize-item :item="federation" />
|
||||
</BListGroup-item>
|
||||
</BListGroup>
|
||||
</BListGroup-item>
|
||||
</BListGroup>
|
||||
</BCol>
|
||||
</BRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, toRefs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDateLocale } from '@/composables/useDateLocale'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import EditableGroup from '@/components/input/EditableGroup.vue'
|
||||
import FederationVisualizeItem from './FederationVisualizeItem.vue'
|
||||
import { updateHomeCommunity } from '@/graphql/updateHomeCommunity'
|
||||
import Coordinates from '../input/Coordinates.vue'
|
||||
import EditableGroupableLabel from '../input/EditableGroupableLabel.vue'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const { item } = toRefs(props)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { toastSuccess, toastError } = useAppToast()
|
||||
|
||||
const details = ref(false)
|
||||
const gmsApiKey = ref(item.value.gmsApiKey)
|
||||
const location = ref(item.value.location)
|
||||
const originalGmsApiKey = ref(item.value.gmsApiKey)
|
||||
const originalLocation = ref(item.value.location)
|
||||
|
||||
const { mutate: updateHomeCommunityMutation } = useMutation(updateHomeCommunity)
|
||||
|
||||
const verified = computed(() => {
|
||||
if (!item.value.federatedCommunities || item.value.federatedCommunities.length === 0) {
|
||||
return false
|
||||
}
|
||||
return item.value.federatedCommunities.some(
|
||||
(federatedCommunity) =>
|
||||
new Date(federatedCommunity.verifiedAt) >= new Date(federatedCommunity.lastAnnouncedAt),
|
||||
)
|
||||
})
|
||||
|
||||
const icon = computed(() => (verified.value ? 'check' : 'x-circle'))
|
||||
const variant = computed(() => (verified.value ? 'success' : 'danger'))
|
||||
|
||||
const lastAnnouncedAt = computed(() => {
|
||||
if (!item.value.federatedCommunities || item.value.federatedCommunities.length === 0) return ''
|
||||
const minDate = new Date(0)
|
||||
const lastAnnouncedAt = item.value.federatedCommunities.reduce(
|
||||
(lastAnnouncedAt, federateCommunity) => {
|
||||
if (!federateCommunity.lastAnnouncedAt) return lastAnnouncedAt
|
||||
const date = new Date(federateCommunity.lastAnnouncedAt)
|
||||
return date > lastAnnouncedAt ? date : lastAnnouncedAt
|
||||
},
|
||||
minDate,
|
||||
)
|
||||
if (lastAnnouncedAt !== minDate) {
|
||||
return formatDistanceToNow(lastAnnouncedAt, {
|
||||
includeSecond: true,
|
||||
addSuffix: true,
|
||||
locale: useDateLocale(),
|
||||
})
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const createdAt = computed(() => {
|
||||
if (item.value.createdAt) {
|
||||
return formatDistanceToNow(new Date(item.value.createdAt), {
|
||||
includeSecond: true,
|
||||
addSuffix: true,
|
||||
locale: useDateLocale(),
|
||||
})
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const isLocationChanged = computed(() => originalLocation.value !== location.value)
|
||||
const isGMSApiKeyChanged = computed(() => originalGmsApiKey.value !== gmsApiKey.value)
|
||||
const isValidLocation = computed(
|
||||
() => location.value && location.value.latitude && location.value.longitude,
|
||||
)
|
||||
|
||||
const toggleDetails = () => {
|
||||
details.value = !details.value
|
||||
}
|
||||
|
||||
const handleUpdateHomeCommunity = async () => {
|
||||
try {
|
||||
await updateHomeCommunityMutation({
|
||||
uuid: item.value.uuid,
|
||||
gmsApiKey: gmsApiKey.value,
|
||||
location: location.value,
|
||||
})
|
||||
|
||||
if (isLocationChanged.value && isGMSApiKeyChanged.value) {
|
||||
toastSuccess(t('federation.toast_gmsApiKeyAndLocationUpdated'))
|
||||
} else if (isGMSApiKeyChanged.value) {
|
||||
toastSuccess(t('federation.toast_gmsApiKeyUpdated'))
|
||||
} else if (isLocationChanged.value) {
|
||||
toastSuccess(t('federation.toast_gmsLocationUpdated'))
|
||||
}
|
||||
originalLocation.value = location.value
|
||||
originalGmsApiKey.value = gmsApiKey.value
|
||||
} catch (error) {
|
||||
toastError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const resetHomeCommunityEditable = () => {
|
||||
location.value = originalLocation.value
|
||||
gmsApiKey.value = originalGmsApiKey.value
|
||||
}
|
||||
</script>
|
||||
65
admin/src/components/Federation/FederationVisualizeItem.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="federation-visualize-item">
|
||||
<BRow>
|
||||
<BCol>
|
||||
<variant-icon :icon="icon" :variant="variant" />
|
||||
</BCol>
|
||||
<BCol class="ml-1">{{ item.apiVersion }}</BCol>
|
||||
<BCol>
|
||||
<span v-b-tooltip="`${item.createdAt}`">
|
||||
{{ distanceDate(item.createdAt) }}
|
||||
</span>
|
||||
</BCol>
|
||||
<BCol>
|
||||
<span v-b-tooltip="`${item.lastAnnouncedAt}`">
|
||||
{{ distanceDate(item.lastAnnouncedAt) }}
|
||||
</span>
|
||||
</BCol>
|
||||
<BCol>
|
||||
<span v-b-tooltip="`${item.verifiedAt}`">
|
||||
{{ distanceDate(item.verifiedAt) }}
|
||||
</span>
|
||||
</BCol>
|
||||
<BCol>
|
||||
<span v-b-tooltip="`${item.lastErrorAt}`">
|
||||
{{ distanceDate(item.lastErrorAt) }}
|
||||
</span>
|
||||
</BCol>
|
||||
</BRow>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useDateLocale } from '@/composables/useDateLocale'
|
||||
import VariantIcon from '@/components/VariantIcon.vue'
|
||||
|
||||
export default {
|
||||
name: 'FederationVisualizeItem',
|
||||
components: { VariantIcon },
|
||||
props: {
|
||||
item: { type: Object },
|
||||
},
|
||||
computed: {
|
||||
verified() {
|
||||
return new Date(this.item.verifiedAt) >= new Date(this.item.lastAnnouncedAt)
|
||||
},
|
||||
icon() {
|
||||
return this.verified ? 'check' : 'x-circle'
|
||||
},
|
||||
variant() {
|
||||
return this.verified ? 'success' : 'danger'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
distanceDate(dateString) {
|
||||
return dateString
|
||||
? formatDistanceToNow(new Date(dateString), {
|
||||
includeSecond: true,
|
||||
addSuffix: true,
|
||||
locale: useDateLocale(),
|
||||
})
|
||||
: ''
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -1,30 +1,72 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FigureQrCode from './FigureQrCode'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import FigureQrCode from './FigureQrCode.vue'
|
||||
import { QRCanvas } from 'qrcanvas-vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const propsData = {
|
||||
link: '',
|
||||
}
|
||||
// Mock QRCanvas component
|
||||
vi.mock('qrcanvas-vue', () => ({
|
||||
QRCanvas: {
|
||||
name: 'QRCanvas',
|
||||
template: '<div class="mock-qr-canvas"></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('FigureQrCode', () => {
|
||||
let wrapper
|
||||
let mockImage
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(FigureQrCode, { localVue, propsData })
|
||||
}
|
||||
beforeEach(() => {
|
||||
// Mock Image object
|
||||
mockImage = {
|
||||
src: '',
|
||||
onload: null,
|
||||
}
|
||||
global.Image = vi.fn(() => mockImage)
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders the Div Element ".figure-qr-code"', () => {
|
||||
expect(wrapper.find('div.figure-qr-code').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the QRCanvas Element ".canvas"', () => {
|
||||
expect(wrapper.find('.canvas').exists()).toBe(true)
|
||||
wrapper = mount(FigureQrCode, {
|
||||
props: {
|
||||
link: 'https://example.com',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
QRCanvas: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.figure-qr-code').exists()).toBe(true)
|
||||
expect(wrapper.find('.qrbox').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render QRCanvas initially', () => {
|
||||
expect(wrapper.find('.canvas').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders QRCanvas after image loads', async () => {
|
||||
expect(wrapper.vm.showQr).toBe(false)
|
||||
mockImage.onload()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.vm.showQr).toBe(true)
|
||||
expect(wrapper.findComponent(QRCanvas).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sets correct qrOptions', () => {
|
||||
expect(wrapper.vm.qrOptions).toEqual({
|
||||
cellSize: 8,
|
||||
correctLevel: 'H',
|
||||
data: 'https://example.com',
|
||||
logo: { image: null },
|
||||
})
|
||||
})
|
||||
|
||||
it('updates qrOptions when link prop changes', async () => {
|
||||
await wrapper.setProps({ link: 'https://newexample.com' })
|
||||
expect(wrapper.vm.qrOptions.data).toBe('https://newexample.com')
|
||||
})
|
||||
|
||||
it('loads the correct image', () => {
|
||||
expect(mockImage.src).toBe('/img/gdd-coin.png')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="figure-qr-code">
|
||||
<div class="qrbox">
|
||||
<q-r-canvas :options="options" class="canvas" />
|
||||
<q-r-canvas v-if="showQr" :options="qrOptions" class="canvas" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -18,26 +18,26 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
options: {
|
||||
image: null,
|
||||
showQr: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
qrOptions() {
|
||||
return {
|
||||
cellSize: 8,
|
||||
correctLevel: 'H',
|
||||
data: this.link,
|
||||
logo: {
|
||||
image: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
logo: { image: this.image },
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
const image = new Image()
|
||||
image.src = 'img/gdd-coin.png'
|
||||
image.src = '/img/gdd-coin.png'
|
||||
image.onload = () => {
|
||||
this.options = {
|
||||
...this.options,
|
||||
logo: {
|
||||
image,
|
||||
},
|
||||
}
|
||||
this.image = image
|
||||
this.showQr = true
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -45,12 +45,13 @@ export default {
|
||||
<style scoped>
|
||||
.qrbox {
|
||||
padding: 20px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
background-color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 90%;
|
||||
max-width: 300px;
|
||||
padding: 5px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
background-color: rgb(255 255 255);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,124 +1,131 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import NavBar from './NavBar'
|
||||
import { createStore } from 'vuex'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import CONFIG from '../config'
|
||||
import { BNavbar, BNavbarNav, BNavItem } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
// Mock vue-router
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router')
|
||||
return {
|
||||
...actual,
|
||||
useRoute: vi.fn(() => ({
|
||||
name: 'user',
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const apolloMutateMock = jest.fn()
|
||||
const storeDispatchMock = jest.fn()
|
||||
const routerPushMock = jest.fn()
|
||||
|
||||
const stubs = {
|
||||
RouterLink: true,
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
$store: {
|
||||
const createVuexStore = () =>
|
||||
createStore({
|
||||
state: {
|
||||
openCreations: 1,
|
||||
token: 'valid-token',
|
||||
},
|
||||
dispatch: storeDispatchMock,
|
||||
},
|
||||
$router: {
|
||||
push: routerPushMock,
|
||||
},
|
||||
}
|
||||
actions: {
|
||||
logout: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('NavBar', () => {
|
||||
let wrapper
|
||||
let store
|
||||
let router
|
||||
let originalWindow
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(NavBar, { mocks, localVue, stubs })
|
||||
const createWrapper = () => {
|
||||
return mount(NavBar, {
|
||||
global: {
|
||||
components: {
|
||||
BNavbarNav,
|
||||
BNavItem,
|
||||
BNavbar,
|
||||
},
|
||||
plugins: [store, router],
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
},
|
||||
stubs: {
|
||||
BCollapse: { template: '<div><slot></slot></div>' },
|
||||
BNavbarBrand: { template: '<div><slot></slot></div>' },
|
||||
BBadge: { template: '<div><slot></slot></div>' },
|
||||
BNavbarToggle: { template: '<div><slot></slot></div>' },
|
||||
},
|
||||
directives: {
|
||||
vBToggle: {},
|
||||
vBColorMode: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
beforeEach(() => {
|
||||
store = createVuexStore()
|
||||
router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/user', name: 'user' },
|
||||
{ path: '/creation-confirm', name: 'creation-confirm' },
|
||||
{ path: '/contribution-links', name: 'contribution-links' },
|
||||
{ path: '/federation', name: 'federation' },
|
||||
{ path: '/statistic', name: 'statistic' },
|
||||
],
|
||||
})
|
||||
originalWindow = global.window
|
||||
const windowMock = {
|
||||
location: {
|
||||
assign: vi.fn(),
|
||||
},
|
||||
}
|
||||
vi.stubGlobal('window', windowMock)
|
||||
|
||||
it('has a DIV element with the class.component-nabvar', () => {
|
||||
expect(wrapper.find('.component-nabvar').exists()).toBeTruthy()
|
||||
})
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
global.window = originalWindow
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.component-nabvar').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('Navbar Menu', () => {
|
||||
it('has a link to /user', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(0).find('a').attributes('href')).toBe('/user')
|
||||
})
|
||||
|
||||
it('has a link to /creation-confirm', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(1).find('a').attributes('href')).toBe(
|
||||
'/creation-confirm',
|
||||
)
|
||||
})
|
||||
|
||||
it('has a link to /contribution-links', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(2).find('a').attributes('href')).toBe(
|
||||
'/contribution-links',
|
||||
)
|
||||
})
|
||||
|
||||
it('has a link to /federation', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(3).find('a').attributes('href')).toBe('/federation')
|
||||
})
|
||||
|
||||
it('has a link to /statistic', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(4).find('a').attributes('href')).toBe('/statistic')
|
||||
it('has correct menu items', () => {
|
||||
const navItems = wrapper.findAll('.nav-item a')
|
||||
expect(navItems).toHaveLength(8)
|
||||
expect(navItems[0].attributes('href')).toBe('/user')
|
||||
expect(navItems[1].attributes('href')).toBe('/creation-confirm')
|
||||
expect(navItems[2].attributes('href')).toBe('/contribution-links')
|
||||
expect(navItems[3].attributes('href')).toBe('/federation')
|
||||
expect(navItems[4].attributes('href')).toBe('/projectBranding')
|
||||
expect(navItems[5].attributes('href')).toBe('/statistic')
|
||||
})
|
||||
})
|
||||
|
||||
describe('wallet', () => {
|
||||
const windowLocation = window.location
|
||||
beforeEach(async () => {
|
||||
delete window.location
|
||||
window.location = ''
|
||||
await wrapper.findAll('.nav-item').at(5).find('a').trigger('click')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.location
|
||||
window.location = windowLocation
|
||||
})
|
||||
|
||||
it('changes window location to wallet', () => {
|
||||
expect(window.location).toBe('http://localhost/authenticate?token=valid-token')
|
||||
})
|
||||
|
||||
it('dispatches logout to store', () => {
|
||||
expect(storeDispatchMock).toBeCalledWith('logout')
|
||||
it('changes window location to wallet and dispatches logout', async () => {
|
||||
const dispatchSpy = vi.spyOn(store, 'dispatch')
|
||||
await wrapper.vm.handleWallet()
|
||||
expect(window.location).toBe(CONFIG.WALLET_AUTH_URL + 'valid-token')
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('logout')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
const windowLocationMock = jest.fn()
|
||||
const windowLocation = window.location
|
||||
beforeEach(async () => {
|
||||
delete window.location
|
||||
window.location = {
|
||||
assign: windowLocationMock,
|
||||
}
|
||||
await wrapper.findAll('.nav-item').at(6).find('a').trigger('click')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.location
|
||||
window.location = windowLocation
|
||||
})
|
||||
|
||||
it('redirects to /logout', () => {
|
||||
expect(windowLocationMock).toBeCalledWith('http://localhost/login')
|
||||
})
|
||||
|
||||
it('dispatches logout to store', () => {
|
||||
expect(storeDispatchMock).toBeCalledWith('logout')
|
||||
})
|
||||
|
||||
it('has called logout mutation', () => {
|
||||
expect(apolloMutateMock).toBeCalled()
|
||||
it('redirects to login page and dispatches logout', async () => {
|
||||
const dispatchSpy = vi.spyOn(store, 'dispatch')
|
||||
await wrapper.vm.handleLogout()
|
||||
expect(window.location.assign).toHaveBeenCalledWith(CONFIG.WALLET_LOGIN_URL)
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('logout')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,59 +1,117 @@
|
||||
<template>
|
||||
<div class="component-nabvar">
|
||||
<b-navbar toggleable="lg" type="dark" class="bg-dark">
|
||||
<b-navbar-brand class="mb-2" to="/">
|
||||
<img src="img/brand/gradido_logo_w.png" class="navbar-brand-img pl-2" alt="..." />
|
||||
</b-navbar-brand>
|
||||
<BNavbar v-b-color-mode="'dark'" toggleable="lg" variant="light-dark">
|
||||
<BNavbarBrand class="mb-2" to="/">
|
||||
<img
|
||||
src="../../public/img/brand/gradido_logo_w.png"
|
||||
class="navbar-brand-img ps-2"
|
||||
alt="..."
|
||||
/>
|
||||
</BNavbarBrand>
|
||||
|
||||
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
|
||||
<BNavbarToggle v-b-toggle.nav-collapse target="navbar-toggle-collapse" />
|
||||
|
||||
<b-collapse id="nav-collapse" is-nav>
|
||||
<b-navbar-nav>
|
||||
<b-nav-item to="/user">{{ $t('navbar.user_search') }}</b-nav-item>
|
||||
<b-nav-item class="bg-color-creation" to="/creation-confirm">
|
||||
<BCollapse id="nav-collapse" is-nav>
|
||||
<BNavbarNav>
|
||||
<BNavItem :active="isActive('user')" to="/user">
|
||||
{{ $t('navbar.user_search') }}
|
||||
</BNavItem>
|
||||
<BNavItem
|
||||
:active="isActive('creation-confirm')"
|
||||
class="bg-color-creation"
|
||||
to="/creation-confirm"
|
||||
>
|
||||
{{ $t('creation') }}
|
||||
<b-badge v-show="$store.state.openCreations > 0" variant="danger">
|
||||
{{ $store.state.openCreations }}
|
||||
</b-badge>
|
||||
</b-nav-item>
|
||||
<b-nav-item to="/contribution-links">
|
||||
<BBadge v-show="openCreations > 0" variant="danger">
|
||||
{{ openCreations }}
|
||||
</BBadge>
|
||||
</BNavItem>
|
||||
<BNavItem to="/contribution-links" :active="isActive('contribution-links')">
|
||||
{{ $t('navbar.automaticContributions') }}
|
||||
</b-nav-item>
|
||||
<b-nav-item to="/federation">
|
||||
</BNavItem>
|
||||
<BNavItem to="/federation" :active="isActive('federation')">
|
||||
{{ $t('navbar.instances') }}
|
||||
</b-nav-item>
|
||||
<b-nav-item to="/statistic">{{ $t('navbar.statistic') }}</b-nav-item>
|
||||
<b-nav-item @click="wallet">{{ $t('navbar.my-account') }}</b-nav-item>
|
||||
<b-nav-item @click="logout">{{ $t('navbar.logout') }}</b-nav-item>
|
||||
</b-navbar-nav>
|
||||
</b-collapse>
|
||||
</b-navbar>
|
||||
</BNavItem>
|
||||
<BNavItem
|
||||
to="/projectBranding"
|
||||
:active="isActive('projectBranding')"
|
||||
:title="$t('navbar.projectBrandingTooltip')"
|
||||
>
|
||||
{{ $t('navbar.projectBranding') }}
|
||||
</BNavItem>
|
||||
<BNavItem to="/statistic" :active="isActive('statistic')">
|
||||
{{ $t('navbar.statistic') }}
|
||||
</BNavItem>
|
||||
<BNavItem @click="handleWallet">{{ $t('navbar.my-account') }}</BNavItem>
|
||||
<BLink
|
||||
href="https://gradido.net/coin/moderators-tutorial/"
|
||||
class="nav-link"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('help.help') }}
|
||||
</BLink>
|
||||
<BNavItem @click="handleLogout">{{ $t('navbar.logout') }}</BNavItem>
|
||||
</BNavbarNav>
|
||||
</BCollapse>
|
||||
</BNavbar>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
<script setup>
|
||||
import CONFIG from '../config'
|
||||
import { useStore } from 'vuex'
|
||||
import { computed } from 'vue'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { logout } from '../graphql/logout'
|
||||
import {
|
||||
BNavbar,
|
||||
BCollapse,
|
||||
BNavbarNav,
|
||||
BNavItem,
|
||||
BNavbarBrand,
|
||||
BBadge,
|
||||
BNavbarToggle,
|
||||
vBToggle,
|
||||
vBColorMode,
|
||||
BLink,
|
||||
} from 'bootstrap-vue-next'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'navbar',
|
||||
methods: {
|
||||
async logout() {
|
||||
window.location.assign(CONFIG.WALLET_LOGIN_URL)
|
||||
// window.location = CONFIG.WALLET_LOGIN_URL
|
||||
this.$store.dispatch('logout')
|
||||
await this.$apollo.mutate({
|
||||
mutation: logout,
|
||||
})
|
||||
},
|
||||
wallet() {
|
||||
window.location = CONFIG.WALLET_AUTH_URL.replace('{token}', this.$store.state.token)
|
||||
this.$store.dispatch('logout') // logout without redirect
|
||||
},
|
||||
},
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
|
||||
const openCreations = computed(() => store.state.openCreations)
|
||||
|
||||
const currentRouteName = computed(() => {
|
||||
return route.name
|
||||
})
|
||||
|
||||
const { mutate: executeLogout } = useMutation(logout)
|
||||
|
||||
const handleLogout = async () => {
|
||||
window.location.assign(CONFIG.WALLET_LOGIN_URL)
|
||||
// window.location = CONFIG.WALLET_LOGIN_URL
|
||||
await store.dispatch('logout')
|
||||
await executeLogout()
|
||||
}
|
||||
|
||||
const handleWallet = () => {
|
||||
window.location = CONFIG.WALLET_AUTH_URL + store.state.token
|
||||
store.dispatch('logout') // logout without redirect
|
||||
}
|
||||
|
||||
const isActive = (tabRoute) => {
|
||||
return tabRoute === currentRouteName.value
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
<style lang="scss" scoped>
|
||||
.navbar-brand-img {
|
||||
height: 2rem;
|
||||
padding-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.bg-light-dark {
|
||||
background-color: #343a40;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,30 +1,33 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import NotFoundPage from './NotFoundPage'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import NotFoundPage from './NotFoundPage.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
}
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n')
|
||||
|
||||
describe('NotFoundPage', () => {
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(NotFoundPage, { localVue, mocks })
|
||||
}
|
||||
beforeEach(() => {
|
||||
// Mock the t function from useI18n
|
||||
const mockT = vi.fn((key) => key)
|
||||
useI18n.mockReturnValue({ t: mockT })
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a svg', () => {
|
||||
expect(wrapper.find('svg').exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has a back button', () => {
|
||||
expect(wrapper.find('.test-back').exists()).toBeTruthy()
|
||||
wrapper = mount(NotFoundPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: mockT,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders an SVG', () => {
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a back button', () => {
|
||||
expect(wrapper.find('.test-back').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1195,7 +1195,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'not-found',
|
||||
name: 'NotFound',
|
||||
data() {
|
||||
return {
|
||||
anime: {
|
||||
@ -1249,21 +1249,23 @@ export default {
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
/*************swing************/
|
||||
/************* swing ************/
|
||||
@keyframes swing {
|
||||
0% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
}
|
||||
|
||||
/*************swing hair************/
|
||||
/************* swing hair ************/
|
||||
@keyframes swinghair {
|
||||
0% {
|
||||
transform: rotate(6deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
|
||||
@ -1,31 +1,75 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Overlay from './Overlay'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import Overlay from './Overlay.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BButton, BCard, BCol, BContainer, BRow } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const propsData = {
|
||||
item: {},
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => String(d)),
|
||||
}
|
||||
vi.mock('vue-i18n')
|
||||
|
||||
describe('Overlay', () => {
|
||||
const mockT = vi.fn((key) => key)
|
||||
const mockD = vi.fn((date, format) => {
|
||||
if (format === 'month') return 'January'
|
||||
if (format === 'year') return '2023'
|
||||
return date.toISOString()
|
||||
})
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(Overlay, { localVue, mocks, propsData })
|
||||
const mockItem = {
|
||||
amount: '100',
|
||||
contributionDate: '2023-01-15T00:00:00.000Z',
|
||||
memo: 'Test memo',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
beforeEach(() => {
|
||||
useI18n.mockReturnValue({ t: mockT, d: mockD })
|
||||
|
||||
it('has a DIV element with the class.component-overlay', () => {
|
||||
expect(wrapper.find('.component-overlay').exists()).toBeTruthy()
|
||||
wrapper = mount(Overlay, {
|
||||
props: {
|
||||
item: mockItem,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BCard,
|
||||
BRow,
|
||||
BCol,
|
||||
BContainer,
|
||||
BButton,
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
title: '<div>Test Title</div>',
|
||||
text: '<p>Test Text</p>',
|
||||
question: '<p>Test Question?</p>',
|
||||
'submit-btn': '<button>Submit</button>',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.component-overlay').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders slot content correctly', () => {
|
||||
expect(wrapper.find('.display-3').html()).toContain('Test Title')
|
||||
expect(wrapper.html()).toContain('<p>Test Text</p>')
|
||||
expect(wrapper.html()).toContain('<p>Test Question?</p>')
|
||||
expect(wrapper.html()).toContain('<button>Submit</button>')
|
||||
})
|
||||
|
||||
it('displays item properties correctly', () => {
|
||||
expect(wrapper.text()).toContain('100 GDD')
|
||||
expect(wrapper.text()).toContain('Test memo')
|
||||
expect(wrapper.text()).toContain('John Doe')
|
||||
expect(wrapper.text()).toContain('john.doe@example.com')
|
||||
})
|
||||
|
||||
it('emits overlay-cancel event when cancel button is clicked', async () => {
|
||||
await wrapper.find('button.m-3.text-light').trigger('click')
|
||||
expect(wrapper.emitted('overlay-cancel')).toBeTruthy()
|
||||
expect(wrapper.emitted('overlay-cancel')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,60 +1,65 @@
|
||||
<template>
|
||||
<div class="component-overlay">
|
||||
<b-jumbotron class="bg-light p-4">
|
||||
<template #header><slot name="title" /></template>
|
||||
<BCard class="bg-light p-4">
|
||||
<h1 class="display-3"><slot name="title" /></h1>
|
||||
|
||||
<template #lead>
|
||||
<b-row class="mt-4">
|
||||
<b-col class="col-3">{{ $t('transactionlist.amount') }}</b-col>
|
||||
<b-col class="h3">
|
||||
<b>{{ item.amount }} {{ $t('GDD') }}</b>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row>
|
||||
<b-col class="col-3">{{ $t('creation_for_month') }}</b-col>
|
||||
<b-col class="h3">
|
||||
{{ $d(new Date(item.contributionDate), 'month') }}
|
||||
{{ $d(new Date(item.contributionDate), 'year') }}
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row>
|
||||
<b-col class="col-3">{{ $t('transactionlist.memo') }}</b-col>
|
||||
<b-col>{{ item.memo }}</b-col>
|
||||
</b-row>
|
||||
<b-row class="mt-3">
|
||||
<b-col class="col-3">{{ $t('name') }}</b-col>
|
||||
<b-col>{{ item.firstName }} {{ item.lastName }}</b-col>
|
||||
</b-row>
|
||||
<b-row>
|
||||
<b-col class="col-3">{{ $t('e_mail') }}</b-col>
|
||||
<b-col>{{ item.email }}</b-col>
|
||||
</b-row>
|
||||
</template>
|
||||
<!-- <template #lead>-->
|
||||
<BRow class="mt-4">
|
||||
<BCol class="col-3">{{ $t('transactionlist.amount') }}</BCol>
|
||||
<BCol class="h3">
|
||||
<b>{{ item.amount }} {{ $t('GDD') }}</b>
|
||||
</BCol>
|
||||
</BRow>
|
||||
<BRow>
|
||||
<BCol class="col-3">{{ $t('creation_for_month') }}</BCol>
|
||||
<BCol class="h3">
|
||||
{{ $d(new Date(item.contributionDate), 'month') }}
|
||||
{{ $d(new Date(item.contributionDate), 'year') }}
|
||||
</BCol>
|
||||
</BRow>
|
||||
<BRow>
|
||||
<BCol class="col-3">{{ $t('transactionlist.memo') }}</BCol>
|
||||
<BCol>{{ item.memo }}</BCol>
|
||||
</BRow>
|
||||
<BRow class="mt-3">
|
||||
<BCol class="col-3">{{ $t('name') }}</BCol>
|
||||
<BCol>{{ item.firstName }} {{ item.lastName }}</BCol>
|
||||
</BRow>
|
||||
<BRow>
|
||||
<BCol class="col-3">{{ $t('e_mail') }}</BCol>
|
||||
<BCol>{{ item.email }}</BCol>
|
||||
</BRow>
|
||||
|
||||
<hr class="my-4" />
|
||||
<slot name="text" />
|
||||
<slot name="question" />
|
||||
|
||||
<b-container>
|
||||
<b-row>
|
||||
<b-col>
|
||||
<b-button size="md" variant="info" class="m-3" @click="$emit('overlay-cancel')">
|
||||
<BContainer>
|
||||
<BRow>
|
||||
<BCol>
|
||||
<BButton
|
||||
size="md"
|
||||
variant="info"
|
||||
class="m-3 text-light"
|
||||
@click="$emit('overlay-cancel')"
|
||||
>
|
||||
{{ $t('overlay.cancel') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-right">
|
||||
</BButton>
|
||||
</BCol>
|
||||
<BCol class="text-end">
|
||||
<slot name="submit-btn" />
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-container>
|
||||
</b-jumbotron>
|
||||
</BCol>
|
||||
</BRow>
|
||||
</BContainer>
|
||||
</BCard>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'overlay',
|
||||
name: 'Overlay',
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
},
|
||||
emits: ['overlay-cancel'],
|
||||
}
|
||||
</script>
|
||||
|
||||
86
admin/src/components/ProjectBranding/ListHumhubSpaces.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<ul class="list-unstyled list-group mb-3">
|
||||
<li
|
||||
v-for="space in spaces"
|
||||
:key="space.id"
|
||||
:title="space.description"
|
||||
:class="[
|
||||
'list-group-item',
|
||||
'list-group-item-action',
|
||||
'd-flex',
|
||||
'justify-content-between',
|
||||
'align-items-center',
|
||||
'cursor-pointer',
|
||||
{ active: space.id === selectedSpaceId },
|
||||
]"
|
||||
@click="chooseSpace(space)"
|
||||
>
|
||||
<div>
|
||||
<input v-model="selectedSpaceId" type="radio" :value="space.id" class="me-2" />
|
||||
{{ space.name }}
|
||||
</div>
|
||||
<a :href="space.url" target="_blank" @click.stop>
|
||||
{{ $t('projectBranding.openSpaceInHumhub') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<b-pagination
|
||||
v-if="result && paginationTotal > ITEMS_PER_PAGE"
|
||||
v-model="paginationPage"
|
||||
:total-rows="paginationTotal"
|
||||
:per-page="ITEMS_PER_PAGE"
|
||||
aria-controls="list-humhub-spaces"
|
||||
@update:model-value="refetch({ page: $event })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { spaces as spacesQuery } from '@/graphql/projectBranding.graphql'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['chooseSpace'])
|
||||
function chooseSpace(space) {
|
||||
selectedSpaceId.value = space.id
|
||||
emit('chooseSpace', space)
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 20
|
||||
const page = ref(1)
|
||||
const selectedSpaceId = ref(props.modelValue)
|
||||
const { result, refetch } = useQuery(spacesQuery, { page: page.value, limit: ITEMS_PER_PAGE })
|
||||
|
||||
const spaces = computed(() => result.value?.spaces?.results || [])
|
||||
const paginationTotal = computed(() => result.value?.spaces?.pagination?.total || 0)
|
||||
const paginationPage = computed(() => result.value?.spaces?.pagination?.page || 1)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue) {
|
||||
if (!spaces.value.some((space) => space.id === props.modelValue)) {
|
||||
const targetPage = Math.ceil(props.modelValue / ITEMS_PER_PAGE)
|
||||
page.value = targetPage
|
||||
refetch({ page: targetPage })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.list-group-item-action:hover:not(.active) {
|
||||
background-color: #ececec;
|
||||
color: #0056b3;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.list-group-item-action.active > a {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
171
admin/src/components/ProjectBranding/ProjectBrandingForm.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="project-branding-form">
|
||||
<BForm @submit.prevent="submit">
|
||||
<ValidatedInput
|
||||
:model-value="name"
|
||||
name="name"
|
||||
:label="$t('name')"
|
||||
:rules="validationSchema.fields.name"
|
||||
class="mb-3"
|
||||
@update:model-value="updateField"
|
||||
/>
|
||||
<ValidatedInput
|
||||
:model-value="alias"
|
||||
name="alias"
|
||||
:label="$t('alias')"
|
||||
:rules="validationSchema.fields.alias"
|
||||
class="mb-3"
|
||||
@update:model-value="updateField"
|
||||
/>
|
||||
<ValidatedInput
|
||||
:model-value="description"
|
||||
name="description"
|
||||
:label="$t('description')"
|
||||
:rules="validationSchema.fields.description"
|
||||
textarea="true"
|
||||
class="mb-3"
|
||||
@update:model-value="updateField"
|
||||
/>
|
||||
<BButton
|
||||
variant="outline-secondary"
|
||||
class="mb-3"
|
||||
:title="result?.space.description"
|
||||
@click="isModalVisible = true"
|
||||
>
|
||||
{{ selectedSpaceText }}
|
||||
</BButton>
|
||||
<BFormGroup
|
||||
:label="$t('projectBranding.newUserToSpace')"
|
||||
label-for="newUserToSpace-input-field"
|
||||
class="mb-3"
|
||||
>
|
||||
<BFormCheckbox
|
||||
id="newUserToSpace-input-field"
|
||||
:model-value="newUserToSpace"
|
||||
name="newUserToSpace"
|
||||
value="true"
|
||||
unchecked-value="false"
|
||||
@update:model-value="(value) => updateField(value, 'newUserToSpace')"
|
||||
>
|
||||
{{ $t('projectBranding.newUserToSpaceTooltip') }}
|
||||
</BFormCheckbox>
|
||||
</BFormGroup>
|
||||
<ValidatedInput
|
||||
:model-value="logoUrl"
|
||||
name="logoUrl"
|
||||
:label="$t('logo')"
|
||||
:rules="validationSchema.fields.logoUrl"
|
||||
class="mb-3"
|
||||
@update:model-value="updateField"
|
||||
/>
|
||||
<BFormInvalidFeedback v-if="errorMessage" class="d-block mb-3">
|
||||
{{ errorMessage }}
|
||||
</BFormInvalidFeedback>
|
||||
<div class="d-flex gap-2">
|
||||
<BButton type="submit" variant="primary">{{ $t('save') }}</BButton>
|
||||
<BButton type="reset" variant="secondary" @click="resetForm">{{ $t('reset') }}</BButton>
|
||||
</div>
|
||||
</BForm>
|
||||
<BModal v-model="isModalVisible" title="Select Space" hide-footer>
|
||||
<ListHumhubSpaces :model-value="spaceId" @choose-space="chooseSpace" />
|
||||
</BModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ValidatedInput from '@/components/input/ValidatedInput'
|
||||
import ListHumhubSpaces from '@/components/ProjectBranding/ListHumhubSpaces.vue'
|
||||
import { spaceWithNameAndDescription } from '@/graphql/projectBranding.graphql'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { reactive, computed, watch, ref } from 'vue'
|
||||
import { object, string, boolean, number } from 'yup'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const form = reactive({ ...props.modelValue })
|
||||
const isModalVisible = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const { t } = useI18n()
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => Object.assign(form, newValue),
|
||||
)
|
||||
const name = computed(() => form.name)
|
||||
const alias = computed(() => form.alias)
|
||||
const description = computed(() => form.description)
|
||||
const spaceId = computed(() => form.spaceId)
|
||||
const newUserToSpace = computed(() => form.newUserToSpace)
|
||||
const logoUrl = computed(() => form.logoUrl)
|
||||
|
||||
// show space
|
||||
const { result } = useQuery(spaceWithNameAndDescription, () => ({ id: spaceId.value }), {
|
||||
enabled: !!spaceId.value,
|
||||
})
|
||||
|
||||
const selectedSpaceText = computed(() => {
|
||||
if (!spaceId.value) {
|
||||
return t('projectBranding.selectSpace')
|
||||
}
|
||||
if (!result.value?.space) {
|
||||
return t('projectBranding.noAccessRightSpace', { spaceId: spaceId.value })
|
||||
}
|
||||
return t('projectBranding.chosenSpace', { space: result.value.space.name })
|
||||
})
|
||||
|
||||
const validationSchema = object({
|
||||
name: string().min(3).max(255).required(),
|
||||
alias: string()
|
||||
.matches(/^[a-z0-9-_]+$/, {
|
||||
message: 'Alias can only contain lowercase letters, numbers, hyphens, and underscores.',
|
||||
})
|
||||
.min(3)
|
||||
.max(32)
|
||||
.required(),
|
||||
description: string().nullable().optional(),
|
||||
spaceId: number().nullable().optional(),
|
||||
spaceUrl: string().url('Space URL must be a valid URL.').max(255).nullable().optional(),
|
||||
newUserToSpace: boolean().optional(),
|
||||
logoUrl: string().url('Logo URL must be a valid URL.').max(255).nullable().optional(),
|
||||
})
|
||||
|
||||
function chooseSpace(value) {
|
||||
updateField(value.id, 'spaceId')
|
||||
updateField(value.url, 'spaceUrl')
|
||||
}
|
||||
|
||||
function updateField(value, name) {
|
||||
form[name] = value
|
||||
}
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
function submit() {
|
||||
validationSchema
|
||||
.validate(form, { stripUnknown: true })
|
||||
.then((cleanedForm) => {
|
||||
emit('update:modelValue', { ...cleanedForm, id: props.modelValue.id })
|
||||
})
|
||||
.catch((err) => {
|
||||
errorMessage.value = err.message
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (props.modelValue.id === undefined) {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
alias: '',
|
||||
description: undefined,
|
||||
spaceId: undefined,
|
||||
spaceUrl: undefined,
|
||||
newUserToSpace: false,
|
||||
logoUrl: undefined,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
Object.assign(form, props.modelValue)
|
||||
}
|
||||
errorMessage.value = ''
|
||||
}
|
||||
</script>
|
||||
117
admin/src/components/ProjectBranding/ProjectBrandingItem.vue
Normal file
@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="project-branding-item">
|
||||
<BRow :title="item.description" @click="toggleDetails">
|
||||
<BCol cols="3">
|
||||
{{ item.name }}
|
||||
<br />
|
||||
{{ frontendLoginUrl }}
|
||||
<BButton
|
||||
v-if="frontendLoginUrl"
|
||||
v-b-tooltip.hover
|
||||
variant="secondary"
|
||||
:title="$t('copy-to-clipboard')"
|
||||
@click.stop="copyToClipboard(frontendLoginUrl)"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
</BButton>
|
||||
</BCol>
|
||||
<BCol cols="2">{{ item.alias }}</BCol>
|
||||
<BCol cols="2">
|
||||
<span v-if="item.newUserToSpace" class="text-success">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<span v-else class="text-danger">
|
||||
<i class="fas fa-times"></i>
|
||||
</span>
|
||||
</BCol>
|
||||
<BCol cols="3" class="me-2">
|
||||
<img class="img-fluid" :src="item.logoUrl" :alt="item.logoUrl" />
|
||||
</BCol>
|
||||
<BCol v-if="store.state.moderator.roles.includes('ADMIN')" cols="1">
|
||||
<BButton v-b-tooltip.hover variant="danger" :title="$t('delete')" @click.stop="deleteItem">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</BButton>
|
||||
</BCol>
|
||||
</BRow>
|
||||
<BRow v-if="details || item.id === undefined" class="details">
|
||||
<BCol colspan="5">
|
||||
<BCard>
|
||||
<ProjectBrandingForm :model-value="item" @update:model-value="update" />
|
||||
</BCard>
|
||||
</BCol>
|
||||
</BRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
import ProjectBrandingForm from './ProjectBrandingForm.vue'
|
||||
import { deleteProjectBranding, upsertProjectBranding } from '@/graphql/projectBranding.graphql'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from 'vuex'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import CONFIG from '@/config'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
const { toastSuccess, toastError } = useAppToast()
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
})
|
||||
const { item } = toRefs(props)
|
||||
const details = ref(false)
|
||||
|
||||
const emit = defineEmits(['update:item', 'deleted:item'])
|
||||
const frontendLoginUrl = computed(() => {
|
||||
if (item.value.alias && item.value.alias.length > 0) {
|
||||
return `${CONFIG.WALLET_LOGIN_URL}?project=${item.value.alias}`
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toastSuccess(t('copied-to-clipboard'))
|
||||
} catch (err) {
|
||||
toastError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetails() {
|
||||
if (store.state.moderator.roles.includes('ADMIN')) {
|
||||
details.value = !details.value
|
||||
}
|
||||
}
|
||||
|
||||
function update(form) {
|
||||
const { mutate } = useMutation(upsertProjectBranding)
|
||||
|
||||
mutate({
|
||||
input: { ...form },
|
||||
})
|
||||
.then(({ data }) => {
|
||||
emit('update:item', data.upsertProjectBranding)
|
||||
if (form.id) {
|
||||
toastSuccess(t('projectBranding.updated'))
|
||||
} else {
|
||||
toastSuccess(t('projectBranding.created'))
|
||||
}
|
||||
details.value = false
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(t('projectBranding.error', { message: error.message }))
|
||||
})
|
||||
}
|
||||
function deleteItem() {
|
||||
const { mutate } = useMutation(deleteProjectBranding)
|
||||
|
||||
mutate({
|
||||
id: item.value.id,
|
||||
}).then(() => {
|
||||
emit('deleted:item', item.value.id)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<b-card class="shadow-lg pl-3 pr-3 mb-5 bg-white rounded">
|
||||
<b-card class="shadow-lg ps-3 pe-3 mb-5 bg-white rounded">
|
||||
<slot :name="slotName" />
|
||||
<b-button size="sm" @click="$emit('row-toggle-details', row, index)">
|
||||
<b-icon
|
||||
@ -16,9 +16,10 @@ export default {
|
||||
name: 'RowDetails',
|
||||
props: {
|
||||
row: { required: true, type: Object },
|
||||
slotName: { requried: true, type: String },
|
||||
type: { requried: true, type: String },
|
||||
index: { requried: true, type: Number },
|
||||
slotName: { required: true, type: String },
|
||||
type: { required: true, type: String },
|
||||
index: { required: true, type: Number },
|
||||
},
|
||||
emits: ['row-toggle-details'],
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,156 +1,133 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OpenCreationsTable from './OpenCreationsTable'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { createStore } from 'vuex'
|
||||
import OpenCreationsTable from './OpenCreationsTable.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue({})
|
||||
const apolloQueryMock = jest.fn().mockResolvedValue({})
|
||||
|
||||
const propsData = {
|
||||
items: [
|
||||
{
|
||||
id: 4,
|
||||
firstName: 'Bob',
|
||||
lastName: 'der Baumeister',
|
||||
email: 'bob@baumeister.de',
|
||||
amount: 300,
|
||||
memo: 'Aktives Grundeinkommen für Januar 2022',
|
||||
date: '2022-01-01T00:00:00.000Z',
|
||||
moderatorId: 1,
|
||||
creation: [700, 1000, 1000],
|
||||
__typename: 'PendingCreation',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
firstName: 'Räuber',
|
||||
lastName: 'Hotzenplotz',
|
||||
email: 'raeuber@hotzenplotz.de',
|
||||
amount: 210,
|
||||
memo: 'Aktives Grundeinkommen für Januar 2022',
|
||||
date: '2022-01-01T00:00:00.000Z',
|
||||
moderatorId: null,
|
||||
creation: [790, 1000, 1000],
|
||||
__typename: 'PendingCreation',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
firstName: 'Stephen',
|
||||
lastName: 'Hawking',
|
||||
email: 'stephen@hawking.uk',
|
||||
amount: 330,
|
||||
memo: 'Aktives Grundeinkommen für Januar 2022',
|
||||
date: '2022-01-01T00:00:00.000Z',
|
||||
moderatorId: 1,
|
||||
creation: [670, 1000, 1000],
|
||||
__typename: 'PendingCreation',
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{ key: 'bookmark', label: 'delete' },
|
||||
{ key: 'email', label: 'e_mail' },
|
||||
{ key: 'firstName', label: 'firstname' },
|
||||
{ key: 'lastName', label: 'lastname' },
|
||||
{
|
||||
key: 'amount',
|
||||
label: 'creation',
|
||||
formatter: (value) => {
|
||||
return value + ' GDD'
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: 'text', class: 'text-break' },
|
||||
{
|
||||
key: 'date',
|
||||
label: 'date',
|
||||
formatter: (value) => {
|
||||
return value
|
||||
},
|
||||
},
|
||||
{ key: 'moderator', label: 'moderator' },
|
||||
{ key: 'editCreation', label: 'edit' },
|
||||
{ key: 'confirm', label: 'save' },
|
||||
],
|
||||
toggleDetails: false,
|
||||
hideResubmission: true,
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
query: apolloQueryMock,
|
||||
},
|
||||
$store: {
|
||||
state: {
|
||||
moderator: {
|
||||
id: 1,
|
||||
name: 'test moderator',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
vi.mock('../RowDetails', () => ({ default: { name: 'RowDetails' } }))
|
||||
vi.mock('../EditCreationFormular', () => ({ default: { name: 'EditCreationFormular' } }))
|
||||
vi.mock('../ContributionMessages/ContributionMessagesList', () => ({
|
||||
default: { name: 'ContributionMessagesList' },
|
||||
}))
|
||||
|
||||
describe('OpenCreationsTable', () => {
|
||||
let wrapper
|
||||
let store
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(OpenCreationsTable, { localVue, mocks, propsData })
|
||||
}
|
||||
const mockItems = [
|
||||
{ id: 1, status: 'PENDING', userId: 2, moderatorId: null, messagesCount: 0 },
|
||||
{ id: 2, status: 'CONFIRMED', userId: 3, moderatorId: 1, messagesCount: 2 },
|
||||
]
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
const mockFields = [
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'bookmark', label: 'Bookmark' },
|
||||
{ key: 'memo', label: 'Memo' },
|
||||
{ key: 'editCreation', label: 'Edit' },
|
||||
{ key: 'chatCreation', label: 'Chat' },
|
||||
{ key: 'deny', label: 'Deny' },
|
||||
{ key: 'confirm', label: 'Confirm' },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore({
|
||||
state: {
|
||||
moderator: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
it('has a DIV element with the class .open-creations-table', () => {
|
||||
expect(wrapper.find('div.open-creations-table').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has a table with three rows', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('find first button.bi-pencil-square for open EditCreationFormular ', () => {
|
||||
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has no button.bi-pencil-square for user contribution ', () => {
|
||||
expect(wrapper.findAll('tr').at(2).find('.bi-pencil-square').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('show edit details', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('tr').at(1).find('.bi-pencil-square').trigger('click')
|
||||
})
|
||||
|
||||
it.skip('has a component element with name EditCreationFormular', () => {
|
||||
expect(wrapper.findComponent({ name: 'EditCreationFormular' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it.skip('renders the component component-edit-creation-formular', () => {
|
||||
expect(wrapper.find('div.component-edit-creation-formular').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('call updateStatus', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.updateStatus(4)
|
||||
})
|
||||
|
||||
it('emits update-status', () => {
|
||||
expect(wrapper.vm.$root.$emit('update-status', 4)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('test reload-contribution', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.reloadContribution(3)
|
||||
})
|
||||
|
||||
it('emits reload-contribution', () => {
|
||||
expect(wrapper.emitted('reload-contribution')).toBeTruthy()
|
||||
expect(wrapper.emitted('reload-contribution')[0]).toEqual([3])
|
||||
})
|
||||
wrapper = shallowMount(OpenCreationsTable, {
|
||||
props: {
|
||||
items: mockItems,
|
||||
fields: mockFields,
|
||||
hideResubmission: false,
|
||||
},
|
||||
global: {
|
||||
plugins: [store],
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
},
|
||||
stubs: {
|
||||
BTableLite: true,
|
||||
BButton: true,
|
||||
IBiQuestionSquare: true,
|
||||
IBiBellFill: true,
|
||||
IBiCheck: true,
|
||||
IBiXCircle: true,
|
||||
IBiTrash: true,
|
||||
IBiPencilSquare: true,
|
||||
IBiChatDots: true,
|
||||
IBiExclamationCircleFill: true,
|
||||
IBiQuestionDiamond: true,
|
||||
IBiX: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'BTableLite' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('applies correct row class based on status', () => {
|
||||
const rowClass = wrapper.vm.rowClass({ status: 'CONFIRMED' }, 'row')
|
||||
expect(rowClass).toBe('table-success')
|
||||
})
|
||||
|
||||
it('emits show-overlay event when calling $emit', async () => {
|
||||
const mockItem = mockItems[0]
|
||||
await wrapper.vm.$emit('show-overlay', mockItem, 'delete')
|
||||
expect(wrapper.emitted('show-overlay')).toBeTruthy()
|
||||
expect(wrapper.emitted('show-overlay')[0]).toEqual([mockItem, 'delete'])
|
||||
})
|
||||
|
||||
it('toggles row details correctly', () => {
|
||||
const mockRow = {
|
||||
toggleDetails: vi.fn(),
|
||||
detailsShowing: false,
|
||||
index: 0,
|
||||
item: mockItems[0],
|
||||
}
|
||||
|
||||
wrapper.vm.rowToggleDetails(mockRow, 0)
|
||||
expect(mockRow.toggleDetails).toHaveBeenCalled()
|
||||
expect(wrapper.vm.openRow).toEqual(mockRow)
|
||||
expect(wrapper.vm.slotIndex).toBe(0)
|
||||
expect(wrapper.vm.creationUserData).toEqual(mockItems[0])
|
||||
})
|
||||
|
||||
it('identifies if the item belongs to the current user', () => {
|
||||
expect(wrapper.vm.myself({ userId: 1 })).toBe(true)
|
||||
expect(wrapper.vm.myself({ userId: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('emits update-contributions event', async () => {
|
||||
await wrapper.vm.updateContributions()
|
||||
expect(wrapper.emitted('update-contributions')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits update-status event', async () => {
|
||||
const id = 1
|
||||
await wrapper.vm.updateStatus(id)
|
||||
expect(wrapper.emitted('update-status')).toBeTruthy()
|
||||
expect(wrapper.emitted('update-status')[0]).toEqual([id])
|
||||
})
|
||||
|
||||
it('emits reload-contribution event', async () => {
|
||||
const id = 1
|
||||
await wrapper.vm.reloadContribution(id)
|
||||
expect(wrapper.emitted('reload-contribution')).toBeTruthy()
|
||||
expect(wrapper.emitted('reload-contribution')[0]).toEqual([id])
|
||||
})
|
||||
|
||||
it('gets correct status icon', () => {
|
||||
expect(wrapper.vm.getStatusIcon('IN_PROGRESS')).toBe('question-square')
|
||||
expect(wrapper.vm.getStatusIcon('PENDING')).toBe('bell-fill')
|
||||
expect(wrapper.vm.getStatusIcon('CONFIRMED')).toBe('check')
|
||||
expect(wrapper.vm.getStatusIcon('DENIED')).toBe('x-circle')
|
||||
expect(wrapper.vm.getStatusIcon('DELETED')).toBe('trash')
|
||||
expect(wrapper.vm.getStatusIcon('UNKNOWN')).toBe('default-icon')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="open-creations-table">
|
||||
<b-table-lite
|
||||
<BTableLite
|
||||
:items="items"
|
||||
:fields="fields"
|
||||
caption-top
|
||||
@ -10,18 +10,22 @@
|
||||
:tbody-tr-class="rowClass"
|
||||
>
|
||||
<template #cell(status)="row">
|
||||
<b-icon :icon="getStatusIcon(row.item.status)"></b-icon>
|
||||
<IBiQuestionSquare v-if="row.item.status === 'IN_PROGRESS'" />
|
||||
<IBiBellFill v-else-if="row.item.status === 'PENDING'" />
|
||||
<IBiCheck v-else-if="row.item.status === 'CONFIRMED'" />
|
||||
<IBiXCircle v-else-if="row.item.status === 'DENIED'" />
|
||||
<IBiTrash v-else-if="row.item.status === 'DELETED'" />
|
||||
</template>
|
||||
<template #cell(bookmark)="row">
|
||||
<div v-if="!myself(row.item)">
|
||||
<b-button
|
||||
<BButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
class="me-2"
|
||||
@click="$emit('show-overlay', row.item, 'delete')"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon icon="trash" variant="light"></b-icon>
|
||||
</b-button>
|
||||
<IBiTrash />
|
||||
</BButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(memo)="row">
|
||||
@ -33,101 +37,100 @@
|
||||
</template>
|
||||
<template #cell(editCreation)="row">
|
||||
<div v-if="!myself(row.item)">
|
||||
<b-button
|
||||
<BButton
|
||||
v-if="row.item.moderatorId"
|
||||
variant="info"
|
||||
size="md"
|
||||
:index="0"
|
||||
class="me-2"
|
||||
@click="rowToggleDetails(row, 0)"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon>
|
||||
</b-button>
|
||||
<b-button v-else @click="rowToggleDetails(row, 0)">
|
||||
<b-icon icon="chat-dots"></b-icon>
|
||||
<b-icon
|
||||
<IBiX v-if="row.detailsShowing" />
|
||||
<IBiPencilSquare v-else />
|
||||
</BButton>
|
||||
<BButton v-else @click="rowToggleDetails(row, 0)">
|
||||
<IBiChatDots />
|
||||
<IBiExclamationCircleFill
|
||||
v-if="row.item.status === 'PENDING' && row.item.messagesCount > 0"
|
||||
icon="exclamation-circle-fill"
|
||||
variant="warning"
|
||||
></b-icon>
|
||||
<b-icon
|
||||
style="color: #ffc107"
|
||||
/>
|
||||
<IBiQuestionDiamond
|
||||
v-if="row.item.status === 'IN_PROGRESS' && row.item.messagesCount > 0"
|
||||
icon="question-diamond"
|
||||
variant="warning"
|
||||
class="pl-1"
|
||||
></b-icon>
|
||||
</b-button>
|
||||
style="color: #ffc107"
|
||||
class="ps-1"
|
||||
/>
|
||||
</BButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(chatCreation)="row">
|
||||
<b-button v-if="row.item.messagesCount > 0" @click="rowToggleDetails(row, 0)">
|
||||
<b-icon icon="chat-dots"></b-icon>
|
||||
</b-button>
|
||||
<BButton v-if="row.item.messagesCount > 0" @click="rowToggleDetails(row, 0)">
|
||||
<IBiChatDots />
|
||||
</BButton>
|
||||
<collapse-icon v-else :visible="row.detailsShowing" @click="rowToggleDetails(row, 0)" />
|
||||
</template>
|
||||
<template #cell(deny)="row">
|
||||
<div v-if="!myself(row.item)">
|
||||
<b-button
|
||||
<BButton
|
||||
variant="warning"
|
||||
size="md"
|
||||
class="me-2"
|
||||
@click="$emit('show-overlay', row.item, 'deny')"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon icon="x" variant="light"></b-icon>
|
||||
</b-button>
|
||||
<IBiX />
|
||||
</BButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(confirm)="row">
|
||||
<div v-if="!myself(row.item)">
|
||||
<b-button
|
||||
<BButton
|
||||
variant="success"
|
||||
size="md"
|
||||
class="me-2"
|
||||
@click="$emit('show-overlay', row.item, 'confirm')"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon icon="check" scale="2" variant=""></b-icon>
|
||||
</b-button>
|
||||
<IBiCheck />
|
||||
</BButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #row-details="row">
|
||||
<row-details
|
||||
:row="row"
|
||||
type="show-creation"
|
||||
slotName="show-creation"
|
||||
slot-name="show-creation"
|
||||
:index="0"
|
||||
@row-toggle-details="rowToggleDetails(row, 0)"
|
||||
>
|
||||
<template #show-creation>
|
||||
<div v-if="row.item.moderatorId">
|
||||
<edit-creation-formular
|
||||
v-if="row.item.confirmedAt === null"
|
||||
type="singleCreation"
|
||||
:item="row.item"
|
||||
:row="row"
|
||||
:creationUserData="creationUserData"
|
||||
:creation-user-data="creationUserData"
|
||||
@update-creation-data="$emit('update-contributions')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<contribution-messages-list
|
||||
:contributionId="row.item.id"
|
||||
:contributionStatus="row.item.status"
|
||||
:contributionUserId="row.item.userId"
|
||||
:contributionMemo="row.item.memo"
|
||||
:resubmissionAt="row.item.resubmissionAt"
|
||||
:hideResubmission="hideResubmission"
|
||||
:contribution="row.item"
|
||||
:resubmission-at="row.item.resubmissionAt"
|
||||
:hide-resubmission="hideResubmission"
|
||||
@update-status="updateStatus"
|
||||
@reload-contribution="reloadContribution"
|
||||
@update-contributions="updateContributions"
|
||||
@search-for-email="$emit('search-for-email', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</row-details>
|
||||
</template>
|
||||
</b-table-lite>
|
||||
</BTableLite>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { toggleRowDetails } from '../../mixins/toggleRowDetails'
|
||||
import RowDetails from '../RowDetails'
|
||||
import EditCreationFormular from '../EditCreationFormular'
|
||||
import ContributionMessagesList from '../ContributionMessages/ContributionMessagesList'
|
||||
@ -142,7 +145,6 @@ const iconMap = {
|
||||
|
||||
export default {
|
||||
name: 'OpenCreationsTable',
|
||||
mixins: [toggleRowDetails],
|
||||
components: {
|
||||
EditCreationFormular,
|
||||
RowDetails,
|
||||
@ -166,6 +168,26 @@ export default {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'update-contributions',
|
||||
'reload-contribution',
|
||||
'update-status',
|
||||
'show-overlay',
|
||||
'search-for-email',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
slotIndex: 0,
|
||||
openRow: null,
|
||||
creationUserData: {},
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.addClipboardListener()
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.removeClipboardListener()
|
||||
},
|
||||
methods: {
|
||||
myself(item) {
|
||||
return item.userId === this.$store.state.moderator.id
|
||||
@ -190,6 +212,40 @@ export default {
|
||||
updateContributions() {
|
||||
this.$emit('update-contributions')
|
||||
},
|
||||
rowToggleDetails(row, index) {
|
||||
const isSameRow = this.openRow && this.openRow.index === row.index
|
||||
const isSameSlot = index === this.slotIndex
|
||||
|
||||
if (isSameRow && isSameSlot) {
|
||||
row.toggleDetails()
|
||||
this.openRow = null
|
||||
} else {
|
||||
if (this.openRow) {
|
||||
this.openRow.toggleDetails()
|
||||
}
|
||||
row.toggleDetails()
|
||||
this.slotIndex = index
|
||||
this.openRow = row
|
||||
this.creationUserData = row.item
|
||||
}
|
||||
},
|
||||
addClipboardListener() {
|
||||
document.addEventListener('copy', this.handleCopy)
|
||||
},
|
||||
removeClipboardListener() {
|
||||
document.removeEventListener('copy', this.handleCopy)
|
||||
},
|
||||
handleCopy(event) {
|
||||
// get from user selected text
|
||||
const selectedText = window.getSelection().toString()
|
||||
|
||||
if (selectedText) {
|
||||
// remove hashtags
|
||||
const cleanedText = selectedText.replace(/#([\p{L}\p{N}_-]+)/gu, '')
|
||||
event.clipboardData.setData('text/plain', cleanedText)
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,10 +1,59 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SearchUserTable from './SearchUserTable'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createStore } from 'vuex'
|
||||
import SearchUserTable from './SearchUserTable.vue'
|
||||
import { BTable } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue({})
|
||||
const apolloQueryMock = jest.fn().mockResolvedValue({})
|
||||
vi.mock('../CreationFormular.vue', () => ({
|
||||
default: {
|
||||
template:
|
||||
'<div class="component-creation-formular"><button @click="emitUpdateUserData">Update User Data</button></div>',
|
||||
methods: {
|
||||
emitUpdateUserData() {
|
||||
this.$emit('update-user-data', this.item, [250, 500, 750])
|
||||
},
|
||||
},
|
||||
props: ['item'],
|
||||
},
|
||||
}))
|
||||
vi.mock('../ConfirmRegisterMailFormular.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="confirm-register-mail-formular"><slot></slot></div>',
|
||||
},
|
||||
}))
|
||||
vi.mock('../CreationTransactionList.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="creation-transaction-list"><slot></slot></div>',
|
||||
},
|
||||
}))
|
||||
vi.mock('../TransactionLinkList.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="transaction-link-list"><slot></slot></div>',
|
||||
},
|
||||
}))
|
||||
vi.mock('../ChangeUserRoleFormular.vue', () => ({
|
||||
default: {
|
||||
template:
|
||||
'<div class="change-user-role-formular"><button @click="emitUpdateRoles">Update Roles</button></div>',
|
||||
methods: {
|
||||
emitUpdateRoles() {
|
||||
this.$emit('updateRoles', { userId: 1, roles: ['ADMIN'] })
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
vi.mock('../DeletedUserFormular.vue', () => ({
|
||||
default: {
|
||||
template:
|
||||
'<div class="deleted-user-formular"><button @click="emitUpdateDeletedAt">Update Deleted At</button></div>',
|
||||
methods: {
|
||||
emitUpdateDeletedAt() {
|
||||
this.$emit('updateDeletedAt', { userId: 1, deletedAt: new Date() })
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const propsData = {
|
||||
items: [
|
||||
@ -52,89 +101,88 @@ const propsData = {
|
||||
{
|
||||
key: 'creation',
|
||||
label: 'creationLabel',
|
||||
formatter: (value, key, item) => {
|
||||
return value.join(' | ')
|
||||
},
|
||||
formatter: (value) => value.join(' | '),
|
||||
},
|
||||
{ key: 'status', label: 'status' },
|
||||
],
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
query: apolloQueryMock,
|
||||
},
|
||||
$store: {
|
||||
state: {
|
||||
moderator: {
|
||||
id: 0,
|
||||
name: 'test moderator',
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('SearchUserTable', () => {
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(SearchUserTable, { localVue, mocks, propsData })
|
||||
const createWrapper = () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
})
|
||||
|
||||
const store = createStore({
|
||||
state: {
|
||||
moderator: {
|
||||
id: 0,
|
||||
name: 'test moderator',
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return mount(SearchUserTable, {
|
||||
global: {
|
||||
components: {
|
||||
BTable,
|
||||
},
|
||||
plugins: [i18n, store],
|
||||
stubs: {
|
||||
IPhCaretUpFill: true,
|
||||
IPhCaretDown: true,
|
||||
},
|
||||
},
|
||||
props: propsData,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('has a table with four rows', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(4)
|
||||
})
|
||||
|
||||
describe('show row details', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('tbody > tr').at(1).trigger('click')
|
||||
})
|
||||
|
||||
it('has a table with four rows', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(4)
|
||||
describe('isAdmin', () => {
|
||||
it('emits updateRoles', async () => {
|
||||
const changeUserRoleFormular = wrapper.find('.change-user-role-formular')
|
||||
await changeUserRoleFormular.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update-roles')).toBeTruthy()
|
||||
expect(wrapper.emitted('update-roles')[0]).toEqual([1, ['ADMIN']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('show row details', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('tbody > tr').at(1).trigger('click')
|
||||
describe('deleted at', () => {
|
||||
it('emits updateDeletedAt', async () => {
|
||||
const deletedUserFormular = wrapper.find('.deleted-user-formular')
|
||||
await deletedUserFormular.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update-deleted-at')).toBeTruthy()
|
||||
expect(wrapper.emitted('update-deleted-at')[0][0]).toBe(1)
|
||||
expect(wrapper.emitted('update-deleted-at')[0][1]).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAdmin', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('div.change-user-role-formular').vm.$emit('updateRoles', {
|
||||
userId: 1,
|
||||
roles: ['ADMIN'],
|
||||
})
|
||||
})
|
||||
describe('updateUserData', () => {
|
||||
it('updates the item', async () => {
|
||||
const creationFormular = wrapper.find('.component-creation-formular')
|
||||
await creationFormular.find('button').trigger('click')
|
||||
|
||||
it('emits updateIsAdmin', () => {
|
||||
expect(wrapper.emitted('updateRoles')).toEqual([[1, ['ADMIN']]])
|
||||
})
|
||||
})
|
||||
await wrapper.vm.$nextTick() // Wait for the next tick to ensure reactivity has updated
|
||||
|
||||
describe('deleted at', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('div.deleted-user-formular').vm.$emit('updateDeletedAt', {
|
||||
userId: 1,
|
||||
deletedAt: new Date(),
|
||||
})
|
||||
})
|
||||
|
||||
it('emits updateDeletedAt', () => {
|
||||
expect(wrapper.emitted('updateDeletedAt')).toEqual([[1, expect.any(Date)]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUserData', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper
|
||||
.find('div.component-creation-formular')
|
||||
.vm.$emit('update-user-data', propsData.items[1], [250, 500, 750])
|
||||
})
|
||||
|
||||
it('updates the item', () => {
|
||||
expect(wrapper.vm.items[1].creation).toEqual([250, 500, 750])
|
||||
})
|
||||
expect(wrapper.vm.myItems[1].creation).toEqual([250, 500, 750])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="search-user-table">
|
||||
<b-table
|
||||
<BTable
|
||||
tbody-tr-class="pointer"
|
||||
:items="myItems"
|
||||
:fields="fields"
|
||||
@ -9,151 +9,302 @@
|
||||
hover
|
||||
stacked="md"
|
||||
select-mode="single"
|
||||
selectableonRowSelected
|
||||
selectable-on-row-selected
|
||||
@row-clicked="onRowClicked"
|
||||
>
|
||||
<template #cell(creation)="data">
|
||||
<div v-html="data.value"></div>
|
||||
<div v-html="data.value" />
|
||||
</template>
|
||||
|
||||
<template #cell(status)="row">
|
||||
<div class="text-right">
|
||||
<b-avatar v-if="row.item.deletedAt" class="mr-3 test-deleted-icon" variant="light">
|
||||
<b-iconstack font-scale="2">
|
||||
<b-icon stacked icon="person" variant="info" scale="0.75"></b-icon>
|
||||
<b-icon stacked icon="slash-circle" variant="danger"></b-icon>
|
||||
</b-iconstack>
|
||||
</b-avatar>
|
||||
<span v-if="!row.item.deletedAt">
|
||||
<b-avatar
|
||||
<div class="d-flex gap-3 justify-content-end align-items-center">
|
||||
<div
|
||||
v-if="row.item.deletedAt"
|
||||
class="me-3 test-deleted-icon position-relative rounded-circle"
|
||||
style="width: 40px; height: 40px"
|
||||
>
|
||||
<img src="../../assets/icons/circle-slash.png" class="position-absolute" />
|
||||
<img
|
||||
src="../../assets/icons/person.png"
|
||||
class="position-relative"
|
||||
style="transform: translate(50%, 30%)"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="!row.item.deletedAt" class="d-flex gap-2">
|
||||
<div
|
||||
v-if="!row.item.emailChecked"
|
||||
icon="envelope"
|
||||
class="align-center mr-3"
|
||||
variant="danger"
|
||||
></b-avatar>
|
||||
|
||||
<b-avatar
|
||||
v-if="!row.item.hasElopage"
|
||||
variant="danger"
|
||||
class="mr-3"
|
||||
src="img/elopage_favicon.png"
|
||||
></b-avatar>
|
||||
class="me-3 rounded-circle position-relative"
|
||||
style="background-color: #dc3545; width: 40px; height: 40px"
|
||||
>
|
||||
<img
|
||||
src="../../assets/icons/envelope.png"
|
||||
style="transform: translate(30%, 30%); width: 25px; height: 25px"
|
||||
class="position-absolute"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
v-if="!row.item.hasElopage"
|
||||
class="me-3 rounded-circle bg-red-dark"
|
||||
src="../../assets/icons/elopage_favicon.png"
|
||||
style="background-color: #dc3545; width: 40px; height: 40px"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<b-icon
|
||||
variant="dark"
|
||||
:icon="row.detailsShowing ? 'caret-up-fill' : 'caret-down'"
|
||||
<IPhCaretUpFill
|
||||
v-if="row.detailsShowing === 'caret-up-fill'"
|
||||
style="color: #212529"
|
||||
:title="row.item.enabled ? $t('enabled') : $t('deleted')"
|
||||
></b-icon>
|
||||
/>
|
||||
<IPhCaretDown
|
||||
v-else
|
||||
style="color: #212529"
|
||||
:title="row.item.enabled ? $t('enabled') : $t('deleted')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #row-details="row">
|
||||
<b-card ref="rowDetails" class="shadow-lg pl-3 pr-3 mb-5 bg-white rounded">
|
||||
<b-tabs content-class="mt-3">
|
||||
<b-tab :title="$t('creation')" active :disabled="row.item.deletedAt !== null">
|
||||
<BCard ref="rowDetails" class="shadow-lg ps-3 pe-3 mb-5 bg-white rounded">
|
||||
<BTabs content-class="mt-3">
|
||||
<BTab :title="$t('creation')" active :disabled="row.item.deletedAt !== null">
|
||||
<creation-formular
|
||||
v-if="!row.item.deletedAt"
|
||||
pagetype="singleCreation"
|
||||
:creation="row.item.creation"
|
||||
:item="row.item"
|
||||
:creationUserData="creationUserData"
|
||||
:creation-user-data="creationUserData"
|
||||
@update-user-data="updateUserData"
|
||||
/>
|
||||
</b-tab>
|
||||
<b-tab :title="$t('e_mail')" :disabled="row.item.deletedAt !== null">
|
||||
</BTab>
|
||||
<BTab :title="$t('e_mail')" :disabled="row.item.deletedAt !== null">
|
||||
<confirm-register-mail-formular
|
||||
v-if="!row.item.deletedAt"
|
||||
:checked="row.item.emailChecked"
|
||||
:email="row.item.email"
|
||||
:dateLastSend="
|
||||
:date-last-send="
|
||||
row.item.emailConfirmationSend
|
||||
? $d(new Date(row.item.emailConfirmationSend), 'long')
|
||||
: ''
|
||||
"
|
||||
/>
|
||||
</b-tab>
|
||||
<b-tab :title="$t('creationList')" :disabled="row.item.deletedAt !== null">
|
||||
<creation-transaction-list v-if="!row.item.deletedAt" :userId="row.item.userId" />
|
||||
</b-tab>
|
||||
<b-tab :title="$t('transactionlink.name')" :disabled="row.item.deletedAt !== null">
|
||||
<transaction-link-list v-if="!row.item.deletedAt" :userId="row.item.userId" />
|
||||
</b-tab>
|
||||
<b-tab :title="$t('userRole.tabTitle')">
|
||||
<change-user-role-formular :item="row.item" @updateRoles="updateRoles" />
|
||||
</b-tab>
|
||||
<b-tab v-if="$store.state.moderator.roles.includes('ADMIN')" :title="$t('delete_user')">
|
||||
<deleted-user-formular :item="row.item" @updateDeletedAt="updateDeletedAt" />
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
</b-card>
|
||||
</BTab>
|
||||
<BTab :title="$t('creationList')" :disabled="row.item.deletedAt !== null">
|
||||
<creation-transaction-list v-if="!row.item.deletedAt" :user-id="row.item.userId" />
|
||||
</BTab>
|
||||
<BTab :title="$t('transactionlink.name')" :disabled="row.item.deletedAt !== null">
|
||||
<transaction-link-list v-if="!row.item.deletedAt" :user-id="row.item.userId" />
|
||||
</BTab>
|
||||
<BTab :title="$t('userRole.tabTitle')">
|
||||
<change-user-role-formular
|
||||
ref="userChangeForm"
|
||||
:item="row.item"
|
||||
@update-roles="updateRoles"
|
||||
@show-modal="showModal"
|
||||
/>
|
||||
</BTab>
|
||||
<BTab v-if="store.state.moderator.roles.includes('ADMIN')" :title="$t('delete_user')">
|
||||
<deleted-user-formular
|
||||
v-if="!row.item.deletedAt"
|
||||
ref="deletedUserForm"
|
||||
:item="row.item"
|
||||
@update-deleted-at="updateDeletedAt"
|
||||
@show-delete-modal="showDeleteModal"
|
||||
/>
|
||||
<deleted-user-formular
|
||||
v-else
|
||||
ref="undeletedUserForm"
|
||||
:item="row.item"
|
||||
@show-undelete-modal="showUndeleteModal"
|
||||
/>
|
||||
</BTab>
|
||||
</BTabs>
|
||||
</BCard>
|
||||
</template>
|
||||
</b-table>
|
||||
</BTable>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import CreationFormular from '../CreationFormular'
|
||||
import ConfirmRegisterMailFormular from '../ConfirmRegisterMailFormular'
|
||||
import CreationTransactionList from '../CreationTransactionList'
|
||||
import TransactionLinkList from '../TransactionLinkList'
|
||||
import ChangeUserRoleFormular from '../ChangeUserRoleFormular'
|
||||
import DeletedUserFormular from '../DeletedUserFormular'
|
||||
<script setup>
|
||||
import { ref, nextTick, watch, computed } from 'vue'
|
||||
import { BTable, BTab, BTabs, BCard, useModalController } from 'bootstrap-vue-next'
|
||||
import { useStore } from 'vuex'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import CreationFormular from '../CreationFormular.vue'
|
||||
import ConfirmRegisterMailFormular from '../ConfirmRegisterMailFormular.vue'
|
||||
import CreationTransactionList from '../CreationTransactionList.vue'
|
||||
import TransactionLinkList from '../TransactionLinkList.vue'
|
||||
import ChangeUserRoleFormular from '../ChangeUserRoleFormular.vue'
|
||||
import DeletedUserFormular from '../DeletedUserFormular.vue'
|
||||
|
||||
export default {
|
||||
name: 'SearchUserTable',
|
||||
components: {
|
||||
CreationFormular,
|
||||
ConfirmRegisterMailFormular,
|
||||
CreationTransactionList,
|
||||
TransactionLinkList,
|
||||
ChangeUserRoleFormular,
|
||||
DeletedUserFormular,
|
||||
const { t } = useI18n()
|
||||
const { confirm } = useModalController()
|
||||
const store = useStore()
|
||||
const { toastError, toastSuccess } = useAppToast()
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
creationUserData: {},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateUserData(rowItem, newCreation) {
|
||||
rowItem.creation = newCreation
|
||||
},
|
||||
updateRoles({ userId, roles }) {
|
||||
this.$emit('updateRoles', userId, roles)
|
||||
},
|
||||
updateDeletedAt({ userId, deletedAt }) {
|
||||
this.$emit('updateDeletedAt', userId, deletedAt)
|
||||
},
|
||||
async onRowClicked(item) {
|
||||
const status = this.myItems.find((obj) => obj === item)._showDetails
|
||||
this.myItems.forEach((obj) => {
|
||||
if (obj === item) {
|
||||
obj._showDetails = !status
|
||||
} else {
|
||||
obj._showDetails = false
|
||||
}
|
||||
})
|
||||
await this.$nextTick()
|
||||
if (!status && this.$refs.rowDetails) {
|
||||
this.$refs.rowDetails.focus()
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
myItems() {
|
||||
return this.items.map((item) => {
|
||||
return { ...item, _showDetails: false }
|
||||
})
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const rolesValues = {
|
||||
ADMIN: 'ADMIN',
|
||||
MODERATOR: 'MODERATOR',
|
||||
MODERATOR_AI: 'MODERATOR_AI',
|
||||
USER: 'USER',
|
||||
}
|
||||
|
||||
const userChangeForm = ref()
|
||||
const deletedUserForm = ref()
|
||||
const undeletedUserForm = ref()
|
||||
const myItems = ref()
|
||||
const creationUserData = ref({})
|
||||
const rowDetails = ref()
|
||||
|
||||
const userRoleChangeConfirmationBody = computed(() => {
|
||||
let roleLabel = ''
|
||||
switch (userChangeForm.value.roleSelected) {
|
||||
case rolesValues.ADMIN:
|
||||
roleLabel = t('userRole.selectRoles.admin')
|
||||
break
|
||||
case rolesValues.MODERATOR:
|
||||
roleLabel = t('userRole.selectRoles.moderator')
|
||||
break
|
||||
case rolesValues.MODERATOR_AI:
|
||||
roleLabel = t('userRole.selectRoles.moderatorAi')
|
||||
break
|
||||
default:
|
||||
roleLabel = t('userRole.selectRoles.user')
|
||||
break
|
||||
}
|
||||
return t('overlay.changeUserRole.question', {
|
||||
username: `${selectedRow.value.firstName} ${selectedRow.value.lastName}`,
|
||||
newRole: roleLabel,
|
||||
})
|
||||
})
|
||||
|
||||
const showModal = async () => {
|
||||
await confirm?.({
|
||||
props: {
|
||||
cancelTitle: t('overlay.cancel'),
|
||||
centered: true,
|
||||
hideHeaderClose: true,
|
||||
title: t('overlay.changeUserRole.title'),
|
||||
okTitle: t('overlay.changeUserRole.yes'),
|
||||
okVariant: 'danger',
|
||||
body: userRoleChangeConfirmationBody.value,
|
||||
},
|
||||
})
|
||||
.then((ok) => {
|
||||
if (ok) {
|
||||
userChangeForm.value.updateUserRole(
|
||||
userChangeForm.value.roleSelected,
|
||||
userChangeForm.value.currentRole,
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
const showDeleteModal = async () => {
|
||||
await confirm?.({
|
||||
props: {
|
||||
cancelTitle: t('overlay.cancel'),
|
||||
centered: true,
|
||||
hideHeaderClose: true,
|
||||
title: t('overlay.deleteUser.title'),
|
||||
okTitle: t('overlay.deleteUser.yes'),
|
||||
okVariant: 'danger',
|
||||
static: true,
|
||||
body: t('overlay.deleteUser.question', {
|
||||
username: `${selectedRow.value.firstName} ${selectedRow.value.lastName}`,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.then((ok) => {
|
||||
if (ok) {
|
||||
deletedUserForm.value.deleteUserMutation()
|
||||
}
|
||||
})
|
||||
|
||||
.catch((error) => {
|
||||
toastError(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
const showUndeleteModal = async () => {
|
||||
await confirm?.({
|
||||
props: {
|
||||
cancelTitle: t('overlay.cancel'),
|
||||
centered: true,
|
||||
hideHeaderClose: true,
|
||||
title: t('overlay.undeleteUser.title'),
|
||||
okTitle: t('overlay.undeleteUser.yes'),
|
||||
okVariant: 'success',
|
||||
body: t('overlay.undeleteUser.question', {
|
||||
username: `${selectedRow.value.firstName} ${selectedRow.value.lastName}`,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.then((ok) => {
|
||||
if (ok) {
|
||||
undeletedUserForm.value.undeleteUserMutation()
|
||||
toastSuccess(t('user_recovered'))
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
const updateUserData = (rowItem, newCreation) => {
|
||||
rowItem.creation = newCreation
|
||||
}
|
||||
|
||||
const updateRoles = ({ userId, roles }) => {
|
||||
emit('update-roles', userId, roles)
|
||||
}
|
||||
|
||||
const updateDeletedAt = ({ userId, deletedAt }) => {
|
||||
emit('update-deleted-at', userId, deletedAt)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update-roles', 'update-deleted-at'])
|
||||
|
||||
const selectedRow = computed(() => {
|
||||
return myItems.value.find((obj) => obj._showDetails)
|
||||
})
|
||||
|
||||
const onRowClicked = async (item) => {
|
||||
const status = myItems.value.find((obj) => {
|
||||
return obj?.userId === item?.userId
|
||||
})?._showDetails
|
||||
|
||||
myItems.value.forEach((obj) => {
|
||||
if (obj === item) {
|
||||
obj._showDetails = !status
|
||||
} else {
|
||||
obj._showDetails = false
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
(items) => {
|
||||
myItems.value = items.map((item) => {
|
||||
return { ...item, _showDetails: false }
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@ -1,50 +1,108 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import StatisticTable from './StatisticTable'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import StatisticTable from './StatisticTable.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BTableSimple, BTbody, BTd, BTh, BThead, BTr } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const propsData = {
|
||||
value: {
|
||||
totalUsers: 3113,
|
||||
activeUsers: 1057,
|
||||
deletedUsers: 35,
|
||||
totalGradidoCreated: '4083774.05000000000000000000',
|
||||
totalGradidoDecayed: '-1062639.13634129622923372197',
|
||||
totalGradidoAvailable: '2513565.869444365732411569',
|
||||
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
|
||||
},
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$n: jest.fn((n) => n),
|
||||
$d: jest.fn((d) => d),
|
||||
}
|
||||
vi.mock('vue-i18n')
|
||||
|
||||
describe('StatisticTable', () => {
|
||||
let wrapper
|
||||
const mockT = vi.fn((key) => key)
|
||||
const mockN = vi.fn((n) => n.toFixed(2))
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(StatisticTable, { localVue, mocks, propsData })
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
beforeEach(() => {
|
||||
useI18n.mockReturnValue({
|
||||
t: mockT,
|
||||
n: mockN,
|
||||
})
|
||||
|
||||
it('has a DIV element with the class .statistic-table', () => {
|
||||
expect(wrapper.find('div.statistic-table').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('renders the table', () => {
|
||||
it('with three colunms', () => {
|
||||
expect(wrapper.findAll('thead > tr > th')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('with seven rows', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(7)
|
||||
})
|
||||
wrapper = mount(StatisticTable, {
|
||||
props: {
|
||||
statistics: {
|
||||
totalUsers: 3113,
|
||||
activeUsers: 1057,
|
||||
deletedUsers: 35,
|
||||
totalGradidoCreated: '4083774.05000000000000000000',
|
||||
totalGradidoDecayed: '-1062639.13634129622923372197',
|
||||
totalGradidoAvailable: '2513565.869444365732411569',
|
||||
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BTableSimple,
|
||||
BThead,
|
||||
BTbody,
|
||||
BTr,
|
||||
BTh,
|
||||
BTd,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.statistic-table').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the table with correct structure', () => {
|
||||
expect(wrapper.findAll('thead > tr > th')).toHaveLength(3)
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('displays correct column headers', () => {
|
||||
const headers = wrapper.findAll('th')
|
||||
expect(headers[1].text()).toBe('statistic.count')
|
||||
expect(headers[2].text()).toBe('statistic.details')
|
||||
})
|
||||
|
||||
it('displays total users correctly', () => {
|
||||
const row = wrapper.findAll('tbody > tr')[0]
|
||||
expect(row.findAll('td')[0].text()).toBe('statistic.totalUsers')
|
||||
expect(row.findAll('td')[1].text()).toBe('3113')
|
||||
})
|
||||
|
||||
it('displays active users correctly', () => {
|
||||
const row = wrapper.findAll('tbody > tr')[1]
|
||||
expect(row.findAll('td')[0].text()).toBe('statistic.activeUsers')
|
||||
expect(row.findAll('td')[1].text()).toBe('1057')
|
||||
})
|
||||
|
||||
it('displays deleted users correctly', () => {
|
||||
const row = wrapper.findAll('tbody > tr')[2]
|
||||
expect(row.findAll('td')[0].text()).toBe('statistic.deletedUsers')
|
||||
expect(row.findAll('td')[1].text()).toBe('35')
|
||||
})
|
||||
|
||||
it('displays total Gradido created correctly', () => {
|
||||
const row = wrapper.findAll('tbody > tr')[3]
|
||||
expect(row.findAll('td')[0].text()).toBe('statistic.totalGradidoCreated')
|
||||
expect(row.findAll('td')[1].text()).toContain('4083774.05')
|
||||
expect(row.findAll('td')[2].text()).toBe('4083774.05000000000000000000')
|
||||
})
|
||||
|
||||
it('displays total Gradido decayed correctly', () => {
|
||||
const row = wrapper.findAll('tbody > tr')[4]
|
||||
expect(row.findAll('td')[0].text()).toBe('statistic.totalGradidoDecayed')
|
||||
expect(row.findAll('td')[1].text()).toContain('-1062639.14')
|
||||
expect(row.findAll('td')[1].text()).toContain('GDD')
|
||||
expect(row.findAll('td')[2].text()).toBe('-1062639.13634129622923372197')
|
||||
})
|
||||
|
||||
it('displays total Gradido available correctly', () => {
|
||||
const row = wrapper.findAll('tbody > tr')[5]
|
||||
expect(row.findAll('td')[0].text()).toBe('statistic.totalGradidoAvailable')
|
||||
expect(row.findAll('td')[1].text()).toContain('2513565.87')
|
||||
expect(row.findAll('td')[1].text()).toContain('GDD')
|
||||
expect(row.findAll('td')[2].text()).toBe('2513565.869444365732411569')
|
||||
})
|
||||
|
||||
it('displays total Gradido unbooked decayed correctly', () => {
|
||||
const row = wrapper.findAll('tbody > tr')[6]
|
||||
expect(row.findAll('td')[0].text()).toBe('statistic.totalGradidoUnbookedDecayed')
|
||||
expect(row.findAll('td')[1].text()).toContain('-500474.67')
|
||||
expect(row.findAll('td')[1].text()).toContain('GDD')
|
||||
expect(row.findAll('td')[2].text()).toBe('-500474.6738366222166261272')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,84 +1,96 @@
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="statistic-table">
|
||||
<b-table-simple style="width: auto" class="mt-5" striped stacked="md">
|
||||
<b-thead>
|
||||
<b-tr>
|
||||
<b-th></b-th>
|
||||
<b-th class="text-right">{{ $t('statistic.count') }}</b-th>
|
||||
<b-th class="text-right">{{ $t('statistic.details') }}</b-th>
|
||||
</b-tr>
|
||||
</b-thead>
|
||||
<b-tbody>
|
||||
<b-tr>
|
||||
<b-td>
|
||||
<BTableSimple style="width: auto" class="mt-5" striped stacked="md">
|
||||
<BThead>
|
||||
<BTr>
|
||||
<BTh />
|
||||
<BTh class="text-end">{{ $t('statistic.count') }}</BTh>
|
||||
<BTh class="text-end">{{ $t('statistic.details') }}</BTh>
|
||||
</BTr>
|
||||
</BThead>
|
||||
<BTbody>
|
||||
<BTr>
|
||||
<BTd>
|
||||
<b>{{ $t('statistic.totalUsers') }}</b>
|
||||
</b-td>
|
||||
<b-td class="text-right">{{ value.totalUsers }}</b-td>
|
||||
<b-td></b-td>
|
||||
</b-tr>
|
||||
<b-tr>
|
||||
<b-td>
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ props.statistics.totalUsers }}
|
||||
</BTd>
|
||||
<BTd></BTd>
|
||||
</BTr>
|
||||
<BTr>
|
||||
<BTd>
|
||||
<b>{{ $t('statistic.activeUsers') }}</b>
|
||||
</b-td>
|
||||
<b-td class="text-right">{{ value.activeUsers }}</b-td>
|
||||
<b-td></b-td>
|
||||
</b-tr>
|
||||
<b-tr>
|
||||
<b-td>
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ props.statistics.activeUsers }}
|
||||
</BTd>
|
||||
<BTd></BTd>
|
||||
</BTr>
|
||||
<BTr>
|
||||
<BTd>
|
||||
<b>{{ $t('statistic.deletedUsers') }}</b>
|
||||
</b-td>
|
||||
<b-td class="text-right">{{ value.deletedUsers }}</b-td>
|
||||
<b-td></b-td>
|
||||
</b-tr>
|
||||
<b-tr>
|
||||
<b-td>
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ props.statistics.deletedUsers }}
|
||||
</BTd>
|
||||
<BTd></BTd>
|
||||
</BTr>
|
||||
<BTr>
|
||||
<BTd>
|
||||
<b>{{ $t('statistic.totalGradidoCreated') }}</b>
|
||||
</b-td>
|
||||
<b-td class="text-right">
|
||||
{{ $n(value.totalGradidoCreated, 'decimal') }} {{ $t('GDD') }}
|
||||
</b-td>
|
||||
<b-td class="text-right">{{ value.totalGradidoCreated }}</b-td>
|
||||
</b-tr>
|
||||
<b-tr>
|
||||
<b-td>
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ getDecimal(props.statistics.totalGradidoCreated) }} {{ $t('GDD') }}
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ props.statistics.totalGradidoCreated }}
|
||||
</BTd>
|
||||
</BTr>
|
||||
<BTr>
|
||||
<BTd>
|
||||
<b>{{ $t('statistic.totalGradidoDecayed') }}</b>
|
||||
</b-td>
|
||||
<b-td class="text-right">
|
||||
{{ $n(value.totalGradidoDecayed, 'decimal') }} {{ $t('GDD') }}
|
||||
</b-td>
|
||||
<b-td class="text-right">{{ value.totalGradidoDecayed }}</b-td>
|
||||
</b-tr>
|
||||
<b-tr>
|
||||
<b-td>
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ getDecimal(props.statistics.totalGradidoDecayed) }} {{ $t('GDD') }}
|
||||
</BTd>
|
||||
<BTd class="text-end">{{ props.statistics.totalGradidoDecayed }}</BTd>
|
||||
</BTr>
|
||||
<BTr>
|
||||
<BTd>
|
||||
<b>{{ $t('statistic.totalGradidoAvailable') }}</b>
|
||||
</b-td>
|
||||
<b-td class="text-right">
|
||||
{{ $n(value.totalGradidoAvailable, 'decimal') }} {{ $t('GDD') }}
|
||||
</b-td>
|
||||
<b-td class="text-right">{{ value.totalGradidoAvailable }}</b-td>
|
||||
</b-tr>
|
||||
<b-tr>
|
||||
<b-td>
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ getDecimal(props.statistics.totalGradidoAvailable) }} {{ $t('GDD') }}
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ props.statistics.totalGradidoAvailable }}
|
||||
</BTd>
|
||||
</BTr>
|
||||
<BTr>
|
||||
<BTd>
|
||||
<b>{{ $t('statistic.totalGradidoUnbookedDecayed') }}</b>
|
||||
</b-td>
|
||||
<b-td class="text-right">
|
||||
{{ $n(value.totalGradidoUnbookedDecayed, 'decimal') }} {{ $t('GDD') }}
|
||||
</b-td>
|
||||
<b-td class="text-right">{{ value.totalGradidoUnbookedDecayed }}</b-td>
|
||||
</b-tr>
|
||||
</b-tbody>
|
||||
</b-table-simple>
|
||||
</BTd>
|
||||
<BTd class="text-end">
|
||||
{{ getDecimal(props.statistics.totalGradidoUnbookedDecayed) }}
|
||||
{{ $t('GDD') }}
|
||||
</BTd>
|
||||
<BTd class="text-end">{{ props.statistics.totalGradidoUnbookedDecayed }}</BTd>
|
||||
</BTr>
|
||||
</BTbody>
|
||||
</BTableSimple>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'StatisticTable',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
statistics: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const getDecimal = (toBeParsed) => parseFloat(toBeParsed).toFixed(2)
|
||||
</script>
|
||||
|
||||
@ -1,140 +1,162 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TransactionLinkList from './TransactionLinkList'
|
||||
import { listTransactionLinksAdmin } from '../graphql/listTransactionLinksAdmin.js'
|
||||
import { toastErrorSpy } from '../../test/testSetup'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import TransactionLinkList from './TransactionLinkList.vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import { BPagination, BTable } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
vi.mock('@vue/apollo-composable')
|
||||
vi.mock('vue-i18n')
|
||||
vi.mock('@/composables/useToast')
|
||||
|
||||
const apolloQueryMock = jest.fn()
|
||||
apolloQueryMock.mockResolvedValue({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
count: 8,
|
||||
links: [
|
||||
{
|
||||
amount: '19.99',
|
||||
code: '62ef8236ace7217fbd066c5a',
|
||||
createdAt: '2022-03-24T17:43:09.000Z',
|
||||
deletedAt: null,
|
||||
holdAvailableAmount: '20.51411720068412022949',
|
||||
id: 36,
|
||||
memo: 'Kein Trick, keine Zauberrei,\nbei Gradidio sei dabei!',
|
||||
redeemedAt: null,
|
||||
validUntil: '2022-04-07T17:43:09.000Z',
|
||||
},
|
||||
{
|
||||
amount: '19.99',
|
||||
code: '2b603f36521c617fbd066cef',
|
||||
createdAt: '2022-03-24T17:43:09.000Z',
|
||||
deletedAt: null,
|
||||
holdAvailableAmount: '20.51411720068412022949',
|
||||
id: 37,
|
||||
memo: 'Kein Trick, keine Zauberrei,\nbei Gradidio sei dabei!',
|
||||
redeemedAt: null,
|
||||
validUntil: '2022-04-07T17:43:09.000Z',
|
||||
},
|
||||
{
|
||||
amount: '19.99',
|
||||
code: '0bb789b5bd5b717fbd066eb5',
|
||||
createdAt: '2022-03-24T17:43:09.000Z',
|
||||
deletedAt: '2022-03-24T17:43:09.000Z',
|
||||
holdAvailableAmount: '20.51411720068412022949',
|
||||
id: 40,
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
redeemedAt: '2022-04-07T14:43:09.000Z',
|
||||
validUntil: '2022-04-07T17:43:09.000Z',
|
||||
},
|
||||
{
|
||||
amount: '19.99',
|
||||
code: '2d4a763e516b317fbd066a85',
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
deletedAt: null,
|
||||
holdAvailableAmount: '20.51411720068412022949',
|
||||
id: 33,
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
redeemedAt: null,
|
||||
validUntil: '2022-01-15T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
const mockLinks = [
|
||||
{
|
||||
amount: '19.99',
|
||||
code: '62ef8236ace7217fbd066c5a',
|
||||
createdAt: '2022-03-24T17:43:09.000Z',
|
||||
deletedAt: null,
|
||||
holdAvailableAmount: '20.51411720068412022949',
|
||||
id: 36,
|
||||
memo: 'Kein Trick, keine Zauberrei,\nbei Gradidio sei dabei!',
|
||||
redeemedAt: null,
|
||||
validUntil: '2022-04-07T17:43:09.000Z',
|
||||
},
|
||||
})
|
||||
|
||||
const propsData = {
|
||||
userId: 42,
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$apollo: {
|
||||
query: apolloQueryMock,
|
||||
{
|
||||
amount: '19.99',
|
||||
code: '2b603f36521c617fbd066cef',
|
||||
createdAt: '2022-03-24T17:43:09.000Z',
|
||||
deletedAt: null,
|
||||
holdAvailableAmount: '20.51411720068412022949',
|
||||
id: 37,
|
||||
memo: 'Kein Trick, keine Zauberrei,\nbei Gradidio sei dabei!',
|
||||
redeemedAt: '2022-04-07T14:43:09.000Z',
|
||||
validUntil: '2022-04-07T17:43:09.000Z',
|
||||
},
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
}
|
||||
{
|
||||
amount: '19.99',
|
||||
code: '0bb789b5bd5b717fbd066eb5',
|
||||
createdAt: '2022-03-24T17:43:09.000Z',
|
||||
deletedAt: '2022-03-24T17:43:09.000Z',
|
||||
holdAvailableAmount: '20.51411720068412022949',
|
||||
id: 40,
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
redeemedAt: '2022-04-07T14:43:09.000Z',
|
||||
validUntil: '2022-04-07T17:43:09.000Z',
|
||||
},
|
||||
{
|
||||
amount: '19.99',
|
||||
code: '2d4a763e516b317fbd066a85',
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
deletedAt: null,
|
||||
holdAvailableAmount: '20.51411720068412022949',
|
||||
id: 33,
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
redeemedAt: null,
|
||||
validUntil: '2022-01-15T00:00:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('TransactionLinkList', () => {
|
||||
const mockT = vi.fn((key) => key)
|
||||
const mockD = vi.fn((date) => new Date(date).toISOString())
|
||||
const mockToastError = vi.fn()
|
||||
let wrapper
|
||||
let mockResult
|
||||
let mockError
|
||||
let mockRefetch
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(TransactionLinkList, { localVue, mocks, propsData })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2022-04-01T00:00:00.000Z'))
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = Wrapper()
|
||||
useI18n.mockReturnValue({ t: mockT, d: mockD })
|
||||
useAppToast.mockReturnValue({ toastError: mockToastError })
|
||||
|
||||
mockResult = ref(null)
|
||||
mockError = ref(null)
|
||||
mockRefetch = vi.fn()
|
||||
|
||||
useQuery.mockReturnValue({
|
||||
result: mockResult,
|
||||
error: mockError,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
it('calls the API', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
currentPage: 1,
|
||||
pageSize: 5,
|
||||
userId: 42,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('has 4 items in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('has pagination buttons', () => {
|
||||
expect(wrapper.findComponent({ name: 'BPagination' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('next page', () => {
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.findComponent({ name: 'BPagination' }).vm.$emit('input', 2)
|
||||
})
|
||||
|
||||
it('calls the API again', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
currentPage: 1,
|
||||
pageSize: 5,
|
||||
userId: 42,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('server response with error', () => {
|
||||
beforeEach(() => {
|
||||
apolloQueryMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
wrapper = mount(TransactionLinkList, {
|
||||
props: {
|
||||
userId: 123,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BTable,
|
||||
BPagination,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component with mock data', async () => {
|
||||
mockResult.value = {
|
||||
listTransactionLinksAdmin: {
|
||||
count: mockLinks.length,
|
||||
links: mockLinks,
|
||||
},
|
||||
}
|
||||
await nextTick()
|
||||
expect(wrapper.find('.transaction-link-list').exists()).toBe(true)
|
||||
expect(wrapper.vm.items).toHaveLength(4)
|
||||
expect(wrapper.vm.rows).toBe(4)
|
||||
})
|
||||
|
||||
it('formats amount correctly', () => {
|
||||
const amountField = wrapper.vm.fields.find((f) => f.key === 'amount')
|
||||
expect(amountField.formatter('19.99')).toBe('19.99 GDD')
|
||||
})
|
||||
|
||||
it('formats status correctly for different scenarios', () => {
|
||||
const statusField = wrapper.vm.fields.find((f) => f.key === 'status')
|
||||
|
||||
// Open transaction
|
||||
expect(statusField.formatter(null, null, mockLinks[0])).toBe('open')
|
||||
|
||||
// Deleted transaction
|
||||
expect(statusField.formatter(null, null, mockLinks[2])).toContain('deleted')
|
||||
expect(statusField.formatter(null, null, mockLinks[2])).toContain('2022-03-24T17:43:09.000Z')
|
||||
|
||||
// Redeemed transaction
|
||||
expect(statusField.formatter(null, null, mockLinks[1])).toContain('redeemed')
|
||||
expect(statusField.formatter(null, null, mockLinks[1])).toContain('2022-04-07T14:43:09.000Z')
|
||||
|
||||
// Expired transaction
|
||||
expect(statusField.formatter(null, null, mockLinks[3])).toContain('expired')
|
||||
expect(statusField.formatter(null, null, mockLinks[3])).toContain('2022-01-15T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('displays correct memo', () => {
|
||||
const memoField = wrapper.vm.fields.find((f) => f.key === 'memo')
|
||||
expect(memoField.label).toBe('transactionlist.memo')
|
||||
expect(memoField.class).toBe('text-break')
|
||||
})
|
||||
|
||||
it('formats dates correctly', () => {
|
||||
const createdAtField = wrapper.vm.fields.find((f) => f.key === 'createdAt')
|
||||
const validUntilField = wrapper.vm.fields.find((f) => f.key === 'validUntil')
|
||||
|
||||
expect(createdAtField.formatter('2022-03-24T17:43:09.000Z')).toBe('2022-03-24T17:43:09.000Z')
|
||||
expect(validUntilField.formatter('2022-04-07T17:43:09.000Z')).toBe('2022-04-07T17:43:09.000Z')
|
||||
})
|
||||
|
||||
it('refetches data when currentPage changes', async () => {
|
||||
wrapper.vm.currentPage = 2
|
||||
await nextTick()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refetches data when perPage changes', async () => {
|
||||
wrapper.vm.perPage = 10
|
||||
await nextTick()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,106 +1,91 @@
|
||||
<template>
|
||||
<div class="transaction-link-list">
|
||||
<div v-if="items.length > 0">
|
||||
<div class="h3">{{ $t('transactionlink.name') }}</div>
|
||||
<b-table striped hover :fields="fields" :items="items"></b-table>
|
||||
<div class="h3">{{ t('transactionlink.name') }}</div>
|
||||
<BTable striped hover :fields="fields" :items="items"></BTable>
|
||||
</div>
|
||||
<b-pagination
|
||||
<BPagination
|
||||
v-model="currentPage"
|
||||
pills
|
||||
size="lg"
|
||||
v-model="currentPage"
|
||||
:per-page="perPage"
|
||||
:total-rows="rows"
|
||||
align="center"
|
||||
:hide-ellipsis="true"
|
||||
></b-pagination>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BTable, BPagination } from 'bootstrap-vue-next'
|
||||
import { listTransactionLinksAdmin } from '../graphql/listTransactionLinksAdmin.js'
|
||||
export default {
|
||||
name: 'TransactionLinkList',
|
||||
props: {
|
||||
userId: { type: Number, required: true },
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps({
|
||||
userId: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const { toastError } = useAppToast()
|
||||
|
||||
const items = ref([])
|
||||
const rows = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(5)
|
||||
|
||||
const fields = computed(() => [
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: t('transactionlink.created'),
|
||||
formatter: (value) => d(new Date(value)),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: [],
|
||||
rows: 0,
|
||||
currentPage: 1,
|
||||
perPage: 5,
|
||||
}
|
||||
{
|
||||
key: 'amount',
|
||||
label: t('transactionlist.amount'),
|
||||
formatter: (value) => `${value} GDD`,
|
||||
},
|
||||
methods: {
|
||||
getListTransactionLinks() {
|
||||
this.$apollo
|
||||
.query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.perPage,
|
||||
userId: this.userId,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.rows = result.data.listTransactionLinksAdmin.count
|
||||
this.items = result.data.listTransactionLinksAdmin.links
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
{ key: 'memo', label: t('transactionlist.memo'), class: 'text-break' },
|
||||
{
|
||||
key: 'validUntil',
|
||||
label: t('transactionlink.valid_until'),
|
||||
formatter: (value) => d(new Date(value)),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'status',
|
||||
formatter: (value, key, item) => {
|
||||
if (item.deletedAt) return `${t('deleted')}: ${d(new Date(item.deletedAt))}`
|
||||
if (item.redeemedAt) return `${t('redeemed')}: ${d(new Date(item.redeemedAt))}`
|
||||
if (new Date() > new Date(item.validUntil))
|
||||
return `${t('expired')}: ${d(new Date(item.validUntil))}`
|
||||
return t('open')
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
fields() {
|
||||
return [
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: this.$t('transactionlink.created'),
|
||||
formatter: (value, key, item) => {
|
||||
return this.$d(new Date(value))
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: this.$t('transactionlist.amount'),
|
||||
formatter: (value, key, item) => {
|
||||
return `${value} GDD`
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: this.$t('transactionlist.memo'), class: 'text-break' },
|
||||
{
|
||||
key: 'validUntil',
|
||||
label: this.$t('transactionlink.valid_until'),
|
||||
formatter: (value, key, item) => {
|
||||
return this.$d(new Date(value))
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'status',
|
||||
formatter: (value, key, item) => {
|
||||
// deleted
|
||||
if (item.deletedAt) return this.$t('deleted') + ': ' + this.$d(new Date(item.deletedAt))
|
||||
// redeemed
|
||||
if (item.redeemedAt)
|
||||
return this.$t('redeemed') + ': ' + this.$d(new Date(item.redeemedAt))
|
||||
// expired
|
||||
if (new Date() > new Date(item.validUntil))
|
||||
return this.$t('expired') + ': ' + this.$d(new Date(item.validUntil))
|
||||
// open
|
||||
return this.$t('open')
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.getListTransactionLinks()
|
||||
},
|
||||
watch: {
|
||||
currentPage() {
|
||||
this.getListTransactionLinks()
|
||||
},
|
||||
},
|
||||
}
|
||||
])
|
||||
|
||||
const { result, error, refetch } = useQuery(listTransactionLinksAdmin, () => ({
|
||||
currentPage: currentPage.value,
|
||||
pageSize: perPage.value,
|
||||
userId: props.userId,
|
||||
}))
|
||||
|
||||
watch(result, (newResult) => {
|
||||
if (newResult && newResult.listTransactionLinksAdmin) {
|
||||
rows.value = newResult.listTransactionLinksAdmin.count
|
||||
items.value = newResult.listTransactionLinksAdmin.links
|
||||
}
|
||||
})
|
||||
|
||||
watch(error, (err) => {
|
||||
if (err) {
|
||||
toastError(error.message)
|
||||
}
|
||||
})
|
||||
|
||||
watch([currentPage, perPage], () => {
|
||||
refetch()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,47 +1,87 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UserQuery from './UserQuery'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import UserQuery from './UserQuery.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BFormInput, BInputGroupText } from 'bootstrap-vue-next'
|
||||
|
||||
const localVue = global.localVue
|
||||
vi.mock('vue-i18n')
|
||||
|
||||
const propsData = {
|
||||
userId: 42,
|
||||
}
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
}
|
||||
describe('TransactionLinkList', () => {
|
||||
describe('UserQuery', () => {
|
||||
const mockT = vi.fn((key) => key)
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(UserQuery, { mocks, localVue, propsData })
|
||||
}
|
||||
beforeEach(() => {
|
||||
useI18n.mockReturnValue({ t: mockT })
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has div .input-group', () => {
|
||||
expect(wrapper.find('div .input-group').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has .test-input-criteria', () => {
|
||||
expect(wrapper.find('input.test-input-criteria').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('set value', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await wrapper.find('input.test-input-criteria').setValue('Test2')
|
||||
})
|
||||
|
||||
it('emits input', () => {
|
||||
expect(wrapper.emitted('input')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits input with value "Test2"', () => {
|
||||
expect(wrapper.emitted('input')).toEqual([['Test2']])
|
||||
})
|
||||
wrapper = mount(UserQuery, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
placeholder: '',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BFormInput,
|
||||
BInputGroupText,
|
||||
IIcBaselineClose: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('.test-input-criteria').exists()).toBe(true)
|
||||
expect(wrapper.find('.test-click-clear-criteria').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses default placeholder when not provided', async () => {
|
||||
expect(mockT).toHaveBeenCalledWith('user_search')
|
||||
expect(wrapper.vm.placeholderText).toBe('user_search')
|
||||
})
|
||||
|
||||
it('uses provided placeholder', async () => {
|
||||
await wrapper.setProps({ placeholder: 'Custom Placeholder' })
|
||||
expect(wrapper.vm.placeholderText).toBe('Custom Placeholder')
|
||||
})
|
||||
|
||||
it('updates currentValue when modelValue prop changes', async () => {
|
||||
await wrapper.setProps({ modelValue: 'New Value' })
|
||||
expect(wrapper.vm.currentValue).toBe('New Value')
|
||||
})
|
||||
|
||||
it('emits update:modelValue event when currentValue changes', async () => {
|
||||
const input = wrapper.find('.test-input-criteria')
|
||||
await input.setValue('New Input')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['New Input'])
|
||||
})
|
||||
|
||||
it('clears the input when clear button is clicked', async () => {
|
||||
await wrapper.setProps({ modelValue: 'Initial Value' })
|
||||
const clearButton = wrapper.find('.test-click-clear-criteria')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.vm.currentValue).toBe('')
|
||||
})
|
||||
|
||||
it('handles edge case: empty string input', async () => {
|
||||
await wrapper.setProps({ modelValue: 'Initial Value' })
|
||||
const input = wrapper.find('.test-input-criteria')
|
||||
await input.setValue('')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.find('input[type="text"]').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('handles edge case: input with only spaces', async () => {
|
||||
const input = wrapper.find('.test-input-criteria')
|
||||
await input.setValue(' ')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')[0]).toEqual([' '])
|
||||
})
|
||||
|
||||
it('does not mutate the original modelValue prop', async () => {
|
||||
const originalValue = 'Original'
|
||||
await wrapper.setProps({ modelValue: originalValue })
|
||||
const input = wrapper.find('.test-input-criteria')
|
||||
await input.setValue('New Value')
|
||||
expect(wrapper.props('modelValue')).toBe(originalValue)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,43 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
<div class="d-flex">
|
||||
<BFormInput
|
||||
v-model="currentValue"
|
||||
type="text"
|
||||
class="test-input-criteria"
|
||||
v-model="currentValue"
|
||||
:placeholder="placeholderText"
|
||||
></b-form-input>
|
||||
<b-input-group-append class="test-click-clear-criteria" @click="currentValue = ''">
|
||||
<b-input-group-text class="pointer">
|
||||
<b-icon icon="x" />
|
||||
</b-input-group-text>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
/>
|
||||
<div append class="test-click-clear-criteria" @click="onClear">
|
||||
<BInputGroupText class="pointer h-100">
|
||||
<IIcBaselineClose />
|
||||
</BInputGroupText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'UserQuery',
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentValue: this.value,
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BInputGroupText, BFormInput } from 'bootstrap-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const placeholderText = computed(() => props.placeholder || t('user_search'))
|
||||
|
||||
const onClear = () => {
|
||||
currentValue.value = ''
|
||||
}
|
||||
|
||||
const currentValue = ref(props.modelValue)
|
||||
|
||||
watch(currentValue, (newValue) => {
|
||||
emit('update:modelValue', newValue)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue !== currentValue.value) {
|
||||
currentValue.value = newValue
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
placeholderText() {
|
||||
return this.placeholder || this.$t('user_search')
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentValue() {
|
||||
if (this.value !== this.currentValue) {
|
||||
this.$emit('input', this.currentValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
22
admin/src/components/VariantIcon.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<IBiCheck v-if="props.icon === 'check'" class="icon-variant" />
|
||||
<IBiXCircle v-if="props.icon === 'x-circle'" class="icon-variant" />
|
||||
<IBiPersonFill v-if="props.icon === 'person-fill'" class="icon-variant" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
icon: { type: String, default: '' },
|
||||
variant: { type: String, default: 'success' },
|
||||
})
|
||||
|
||||
const color = ref(`var(--bs-${props.variant}`)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.icon-variant {
|
||||
color: v-bind('color');
|
||||
}
|
||||
</style>
|
||||
113
admin/src/components/input/Coordinates.spec.js
Normal file
@ -0,0 +1,113 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import Coordinates from './Coordinates.vue'
|
||||
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
|
||||
|
||||
const modelValue = {
|
||||
latitude: 56.78,
|
||||
longitude: 12.34,
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, v) => (key === 'geo-coordinates.format' ? `${v.latitude}, ${v.longitude}` : key),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockEditableGroup = {
|
||||
valueChanged: vi.fn(function () {
|
||||
this.isValueChanged = true
|
||||
}),
|
||||
invalidValues: vi.fn(function () {
|
||||
this.isValueChanged = false
|
||||
}),
|
||||
}
|
||||
|
||||
describe('Coordinates', () => {
|
||||
let wrapper
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(Coordinates, {
|
||||
props: {
|
||||
modelValue,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BFormGroup,
|
||||
BFormInput,
|
||||
},
|
||||
provide: {
|
||||
editableGroup: mockEditableGroup,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('renders the component with initial values', () => {
|
||||
expect(wrapper.find('#home-community-latitude').element.value).toBe('56.78')
|
||||
expect(wrapper.find('#home-community-longitude').element.value).toBe('12.34')
|
||||
expect(wrapper.find('#home-community-latitude-longitude-smart').element.value).toBe(
|
||||
'56.78, 12.34',
|
||||
)
|
||||
})
|
||||
|
||||
it('updates latitude and longitude when input changes', async () => {
|
||||
const latitudeInput = wrapper.find('#home-community-latitude')
|
||||
const longitudeInput = wrapper.find('#home-community-longitude')
|
||||
|
||||
await latitudeInput.setValue('34.56')
|
||||
await longitudeInput.setValue('78.90')
|
||||
|
||||
expect(wrapper.vm.inputValue).toStrictEqual({
|
||||
latitude: 34.56,
|
||||
longitude: '78.90',
|
||||
})
|
||||
})
|
||||
|
||||
it('emits input event with updated values', async () => {
|
||||
const latitudeInput = wrapper.find('#home-community-latitude')
|
||||
const longitudeInput = wrapper.find('#home-community-longitude')
|
||||
|
||||
await latitudeInput.setValue('34.56')
|
||||
expect(wrapper.emitted('input')).toBeTruthy()
|
||||
expect(wrapper.vm.inputValue.latitude).toBe('34.56')
|
||||
|
||||
await longitudeInput.setValue('78.9')
|
||||
expect(wrapper.emitted('input')).toBeTruthy()
|
||||
expect(wrapper.vm.inputValue.longitude).toBe('78.9')
|
||||
})
|
||||
|
||||
it('splits coordinates correctly when entering in latitudeLongitude input', async () => {
|
||||
const latitudeLongitudeInput = wrapper.find('#home-community-latitude-longitude-smart')
|
||||
|
||||
await latitudeLongitudeInput.setValue('34.56, 78.90')
|
||||
await latitudeLongitudeInput.trigger('input')
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.vm.inputValue).toStrictEqual({
|
||||
latitude: 34.56,
|
||||
longitude: 78.9,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates coordinates correctly', async () => {
|
||||
const latitudeInput = wrapper.find('#home-community-latitude')
|
||||
const longitudeInput = wrapper.find('#home-community-longitude')
|
||||
|
||||
await latitudeInput.setValue('invalid')
|
||||
await longitudeInput.setValue('78.90')
|
||||
|
||||
expect(wrapper.vm.isValid).toBe(false)
|
||||
|
||||
await latitudeInput.setValue('34.56')
|
||||
await longitudeInput.setValue('78.90')
|
||||
|
||||
expect(wrapper.vm.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
130
admin/src/components/input/Coordinates.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="mb-4">
|
||||
<BFormGroup
|
||||
:label="t('geo-coordinates.label')"
|
||||
:invalid-feedback="t('geo-coordinates.both-or-none')"
|
||||
:state="isValid"
|
||||
>
|
||||
<BFormGroup
|
||||
:label="t('latitude-longitude-smart')"
|
||||
label-for="home-community-latitude-longitude-smart"
|
||||
:description="t('geo-coordinates.latitude-longitude-smart.describe')"
|
||||
>
|
||||
<BFormInput
|
||||
id="home-community-latitude-longitude-smart"
|
||||
v-model="locationString"
|
||||
type="text"
|
||||
@input="splitCoordinates"
|
||||
/>
|
||||
</BFormGroup>
|
||||
<BFormGroup :label="t('latitude')" label-for="home-community-latitude">
|
||||
<BFormInput
|
||||
id="home-community-latitude"
|
||||
v-model="inputValue.latitude"
|
||||
type="text"
|
||||
@input="valueUpdated"
|
||||
/>
|
||||
</BFormGroup>
|
||||
<BFormGroup :label="t('longitude')" label-for="home-community-longitude">
|
||||
<BFormInput
|
||||
id="home-community-longitude"
|
||||
v-model="inputValue.longitude"
|
||||
type="text"
|
||||
@input="valueUpdated"
|
||||
/>
|
||||
</BFormGroup>
|
||||
</BFormGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const editableGroup = inject('editableGroup')
|
||||
|
||||
const inputValue = ref(sanitizeLocation(props.modelValue))
|
||||
const originalValue = ref(props.modelValue)
|
||||
const locationString = ref(getLatitudeLongitudeString(props.modelValue))
|
||||
|
||||
const isValid = computed(() => {
|
||||
return (
|
||||
(!isNaN(parseFloat(inputValue.value.longitude)) &&
|
||||
!isNaN(parseFloat(inputValue.value.latitude))) ||
|
||||
(inputValue.value.longitude === '' && inputValue.value.latitude === '')
|
||||
)
|
||||
})
|
||||
|
||||
const isChanged = computed(() => {
|
||||
return inputValue.value !== originalValue.value
|
||||
})
|
||||
|
||||
function splitCoordinates() {
|
||||
const parts = locationString.value.split(',').map((part) => part.trim())
|
||||
|
||||
if (parts.length === 2) {
|
||||
const [lat, lon] = parts
|
||||
if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
|
||||
inputValue.value.longitude = parseFloat(lon)
|
||||
inputValue.value.latitude = parseFloat(lat)
|
||||
}
|
||||
}
|
||||
valueUpdated()
|
||||
}
|
||||
|
||||
function sanitizeLocation(location) {
|
||||
if (!location) return { latitude: '', longitude: '' }
|
||||
|
||||
const parseNumber = (value) => {
|
||||
const number = parseFloat(value)
|
||||
return isNaN(number) ? '' : number
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: parseNumber(location.latitude),
|
||||
longitude: parseNumber(location.longitude),
|
||||
}
|
||||
}
|
||||
|
||||
function getLatitudeLongitudeString(locationData) {
|
||||
return locationData?.latitude && locationData?.longitude
|
||||
? t('geo-coordinates.format', {
|
||||
latitude: locationData.latitude,
|
||||
longitude: locationData.longitude,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
|
||||
function valueUpdated() {
|
||||
locationString.value = getLatitudeLongitudeString(inputValue.value)
|
||||
inputValue.value = sanitizeLocation(inputValue.value)
|
||||
|
||||
if (isValid.value && isChanged.value) {
|
||||
editableGroup.valueChanged()
|
||||
} else {
|
||||
editableGroup.invalidValues()
|
||||
}
|
||||
|
||||
emit('update:modelValue', inputValue.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
inputValue.value = sanitizeLocation(newValue)
|
||||
originalValue.value = newValue
|
||||
locationString.value = getLatitudeLongitudeString(newValue)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
79
admin/src/components/input/EditableGroup.spec.js
Normal file
@ -0,0 +1,79 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import EditableGroup from './EditableGroup.vue'
|
||||
import { BButton, BFormGroup } from 'bootstrap-vue-next'
|
||||
|
||||
const viewValue = 'test label value'
|
||||
const editValue = 'test edit value'
|
||||
|
||||
describe('EditableGroup', () => {
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(EditableGroup, {
|
||||
props,
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
},
|
||||
stubs: {
|
||||
BFormGroup,
|
||||
BButton,
|
||||
IBiPencilFill: true,
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
view: `<div>${viewValue}</div>`,
|
||||
edit: `<div class='test-edit'>${editValue}</div>`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the view slot when not editing', () => {
|
||||
const wrapper = createWrapper({ allowEdit: true })
|
||||
expect(wrapper.text()).toContain(viewValue)
|
||||
})
|
||||
|
||||
it('renders the edit slot when editing', async () => {
|
||||
const wrapper = createWrapper({ allowEdit: true })
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.find('.test-edit').text()).toBe(editValue)
|
||||
})
|
||||
|
||||
it('emits save event when clicking save button', async () => {
|
||||
const wrapper = createWrapper({ allowEdit: true })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.valueChanged()
|
||||
await wrapper.find('.save-button').trigger('click')
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables save button when value is not changed', async () => {
|
||||
const wrapper = createWrapper({ allowEdit: true })
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.find('.save-button').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables save button when value is changed', async () => {
|
||||
const wrapper = createWrapper({ allowEdit: true })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.vm.valueChanged()
|
||||
expect(wrapper.find('.save-button').attributes('disabled')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('updates variant to success when editing', async () => {
|
||||
const wrapper = createWrapper({ allowEdit: true })
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.vm.variant).toBe('success')
|
||||
})
|
||||
|
||||
it('updates variant to prime when not editing', () => {
|
||||
const wrapper = createWrapper({ allowEdit: true })
|
||||
expect(wrapper.vm.variant).toBe('prime')
|
||||
})
|
||||
|
||||
it('emits reset event when clicking close button', async () => {
|
||||
const wrapper = createWrapper({ allowEdit: true })
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('.close-button').trigger('click')
|
||||
expect(wrapper.emitted('reset')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
73
admin/src/components/input/EditableGroup.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot v-if="!isEditing" :is-editing="isEditing" name="view" />
|
||||
<slot v-else :is-editing="isEditing" name="edit" @update:model-value="valueChanged" />
|
||||
<BFormGroup v-if="allowEdit && !isEditing">
|
||||
<BButton :variant="variant" @click="enableEdit">
|
||||
<IBiPencilFill />
|
||||
{{ $t('edit') }}
|
||||
</BButton>
|
||||
</BFormGroup>
|
||||
<BFormGroup v-else-if="allowEdit && isEditing">
|
||||
<BButton :variant="variant" :disabled="!isValueChanged" class="save-button" @click="save">
|
||||
{{ $t('save') }}
|
||||
</BButton>
|
||||
<BButton variant="secondary" class="close-button ms-2" @click="close">
|
||||
{{ $t('close') }}
|
||||
</BButton>
|
||||
</BFormGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EditableGroup',
|
||||
provide() {
|
||||
return {
|
||||
editableGroup: {
|
||||
valueChanged: this.valueChanged,
|
||||
invalidValues: this.invalidValues,
|
||||
},
|
||||
}
|
||||
},
|
||||
props: {
|
||||
allowEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['save', 'reset'],
|
||||
data() {
|
||||
return {
|
||||
isEditing: false,
|
||||
isValueChanged: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
variant() {
|
||||
return this.isEditing ? 'success' : 'prime'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
enableEdit() {
|
||||
this.isEditing = true
|
||||
},
|
||||
valueChanged() {
|
||||
this.isValueChanged = true
|
||||
},
|
||||
invalidValues() {
|
||||
this.isValueChanged = false
|
||||
},
|
||||
save() {
|
||||
this.$emit('save')
|
||||
this.isEditing = false
|
||||
this.isValueChanged = false
|
||||
},
|
||||
close() {
|
||||
this.$emit('reset')
|
||||
this.isEditing = false
|
||||
this.isValueChanged = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
104
admin/src/components/input/EditableGroupableLabel.spec.js
Normal file
@ -0,0 +1,104 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import EditableGroupableLabel from './EditableGroupableLabel.vue'
|
||||
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
|
||||
|
||||
const modelValue = 'test label value'
|
||||
const label = 'Test Label'
|
||||
const idName = 'test-id-name'
|
||||
|
||||
describe('EditableGroupableLabel', () => {
|
||||
let wrapper
|
||||
|
||||
const createWrapper = (props = {}, parentMethods = {}) => {
|
||||
const Parent = {
|
||||
template: '<editable-groupable-label v-bind="$props" />',
|
||||
components: {
|
||||
EditableGroupableLabel,
|
||||
},
|
||||
props: ['modelValue', 'label', 'idName'],
|
||||
methods: {
|
||||
onInput: vi.fn(),
|
||||
...parentMethods,
|
||||
},
|
||||
}
|
||||
return mount(Parent, {
|
||||
props: {
|
||||
modelValue,
|
||||
label,
|
||||
idName,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BFormGroup,
|
||||
BFormInput,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders BFormGroup with correct props', () => {
|
||||
const formGroup = wrapper.findComponent(BFormGroup)
|
||||
expect(formGroup.props('label')).toBe(label)
|
||||
expect(formGroup.props('labelFor')).toBe(idName)
|
||||
})
|
||||
|
||||
it('renders BFormInput with correct props', () => {
|
||||
const formInput = wrapper.findComponent({ name: 'BFormInput' })
|
||||
expect(formInput.props('id')).toBe(idName)
|
||||
expect(formInput.props('modelValue')).toBe(modelValue)
|
||||
})
|
||||
|
||||
// it('emits input event with the correct value when input changes', async () => {
|
||||
// const newValue = 'new label value'
|
||||
// const editableGroupableLabel = wrapper.findComponent(EditableGroupableLabel)
|
||||
// const input = editableGroupableLabel.findComponent({ name: 'BFormInput' })
|
||||
//
|
||||
// await input.vm.$emit('input', newValue)
|
||||
//
|
||||
// await wrapper.vm.$nextTick()
|
||||
//
|
||||
// expect(wrapper.vm.onInput).toHaveBeenCalledWith(newValue)
|
||||
// })
|
||||
|
||||
it('calls parent.valueChanged when value changes', async () => {
|
||||
const valueChangedMock = vi.fn()
|
||||
wrapper = createWrapper({}, { valueChanged: valueChangedMock })
|
||||
|
||||
const newValue = 'new label value'
|
||||
const input = wrapper.findComponent({ name: 'BFormInput' })
|
||||
await input.vm.$emit('update:model-value', newValue)
|
||||
|
||||
expect(valueChangedMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls parent.invalidValues when value is reverted to original', async () => {
|
||||
const invalidValuesMock = vi.fn()
|
||||
wrapper = createWrapper({}, { invalidValues: invalidValuesMock })
|
||||
|
||||
const input = wrapper.findComponent({ name: 'BFormInput' })
|
||||
await input.vm.$emit('update:model-value', 'new label value')
|
||||
await input.vm.$emit('update:model-value', modelValue)
|
||||
|
||||
expect(invalidValuesMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call parent.valueChanged when value is reverted to original', async () => {
|
||||
const valueChangedMock = vi.fn()
|
||||
wrapper = createWrapper({}, { valueChanged: valueChangedMock })
|
||||
|
||||
const input = wrapper.findComponent({ name: 'BFormInput' })
|
||||
await input.vm.$emit('input', modelValue)
|
||||
|
||||
expect(valueChangedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
52
admin/src/components/input/EditableGroupableLabel.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<BFormGroup :label="label" :label-for="idName">
|
||||
<BFormInput :id="idName" :model-value="modelValue" @update:model-value="inputValue = $event" />
|
||||
</BFormGroup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EditableGroupableLabel',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
idName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:model-value'],
|
||||
data() {
|
||||
return {
|
||||
inputValue: this.modelValue,
|
||||
originalValue: this.modelValue,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
inputValue() {
|
||||
this.updateValue()
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateValue() {
|
||||
if (this.inputValue !== this.originalValue) {
|
||||
if (this.$parent.valueChanged) {
|
||||
this.$parent.valueChanged()
|
||||
}
|
||||
} else {
|
||||
if (this.$parent.invalidValues) {
|
||||
this.$parent.invalidValues()
|
||||
}
|
||||
}
|
||||
this.$emit('update:model-value', this.inputValue)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
45
admin/src/components/input/LabeledInput.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div :class="wrapperClassName">
|
||||
<BFormGroup :label="label" :label-for="labelFor">
|
||||
<BFormTextarea
|
||||
v-if="textarea"
|
||||
v-bind="{ ...$attrs, id: labelFor, name }"
|
||||
v-model="model"
|
||||
trim
|
||||
:rows="4"
|
||||
:max-rows="4"
|
||||
no-resize
|
||||
/>
|
||||
<BFormInput v-else v-bind="{ ...$attrs, id: labelFor, name }" v-model="model" />
|
||||
<slot></slot>
|
||||
</BFormGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineOptions, defineModel } from 'vue'
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
textarea: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
const wrapperClassName = computed(() => (props.name ? `input-${props.name}` : 'input'))
|
||||
const labelFor = computed(() => `${props.name}-input-field`)
|
||||
</script>
|
||||
@ -1,11 +1,12 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import TimePicker from './TimePicker.vue'
|
||||
|
||||
describe('TimePicker', () => {
|
||||
it('updates timeValue on input and emits input event', async () => {
|
||||
it('updates timeValue on input and emits update:modelValue event', async () => {
|
||||
const wrapper = mount(TimePicker, {
|
||||
propsData: {
|
||||
value: '12:34', // Set an initial value for testing
|
||||
props: {
|
||||
modelValue: '12:34', // Set an initial value for testing
|
||||
},
|
||||
})
|
||||
|
||||
@ -15,22 +16,26 @@ describe('TimePicker', () => {
|
||||
await input.setValue('23:45')
|
||||
|
||||
// Check if timeValue is updated
|
||||
expect(wrapper.vm.timeValue).toBe('23:45')
|
||||
expect(wrapper.vm.timeValue).toBe('23:45') // test for Vue 3 composition state directly, if possible
|
||||
|
||||
// Check if input event is emitted with updated value
|
||||
expect(wrapper.emitted().input).toBeTruthy()
|
||||
expect(wrapper.emitted().input[0]).toEqual(['23:45'])
|
||||
// Check if update:modelValue event is emitted with updated value
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['23:45'])
|
||||
})
|
||||
|
||||
it('validates and corrects time format on blur', async () => {
|
||||
const wrapper = mount(TimePicker)
|
||||
const wrapper = mount(TimePicker, {
|
||||
props: {
|
||||
modelValue: '99:99', // Set an invalid value initially
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
|
||||
// Simulate user input
|
||||
await input.setValue('99:99')
|
||||
expect(wrapper.emitted().input).toBeTruthy()
|
||||
expect(wrapper.emitted().input[0]).toEqual(['99:99'])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['99:99'])
|
||||
|
||||
// Trigger blur event
|
||||
await input.trigger('blur')
|
||||
@ -38,26 +43,30 @@ describe('TimePicker', () => {
|
||||
// Check if timeValue is corrected to valid format
|
||||
expect(wrapper.vm.timeValue).toBe('23:59') // Maximum allowed value for hours and minutes
|
||||
|
||||
// Check if input event is emitted with corrected value
|
||||
expect(wrapper.emitted().input).toBeTruthy()
|
||||
expect(wrapper.emitted().input[1]).toEqual(['23:59'])
|
||||
// Check if update:modelValue event is emitted with corrected value
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')[1]).toEqual(['23:59'])
|
||||
})
|
||||
|
||||
it('check handling of empty input', async () => {
|
||||
const wrapper = mount(TimePicker)
|
||||
it('checks handling of empty input', async () => {
|
||||
const wrapper = mount(TimePicker, {
|
||||
props: {
|
||||
modelValue: '', // Set initial empty value
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
|
||||
// Simulate user input with non-numeric characters
|
||||
// Simulate user input with empty string
|
||||
await input.setValue('')
|
||||
|
||||
// Trigger blur event
|
||||
await input.trigger('blur')
|
||||
|
||||
// Check if non-numeric characters are filtered out
|
||||
// Check if empty input is handled correctly
|
||||
expect(wrapper.vm.timeValue).toBe('00:00')
|
||||
|
||||
// Check if input event is emitted with filtered value
|
||||
expect(wrapper.emitted().input).toBeTruthy()
|
||||
expect(wrapper.emitted().input[1]).toEqual(['00:00'])
|
||||
// Check if update:modelValue event is emitted with default value
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')[1]).toEqual(['00:00'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,48 +1,72 @@
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="timeValue"
|
||||
class="timer-input"
|
||||
type="text"
|
||||
placeholder="hh:mm"
|
||||
@input="updateValues"
|
||||
@blur="validateAndCorrect"
|
||||
placeholder="hh:mm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export default {
|
||||
// Code written from chatGPT 3.5
|
||||
name: 'TimePicker',
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '00:00',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timeValue: this.value,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValues(event) {
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
// reactive state
|
||||
const timeValue = ref(props.modelValue)
|
||||
|
||||
// watch for prop changes
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
timeValue.value = newVal
|
||||
},
|
||||
)
|
||||
|
||||
const updateValues = (event) => {
|
||||
// Allow only numbers and ":"
|
||||
const inputValue = event.target.value.replace(/[^0-9:]/g, '')
|
||||
this.timeValue = inputValue
|
||||
this.$emit('input', inputValue)
|
||||
},
|
||||
validateAndCorrect() {
|
||||
let [hours, minutes] = this.timeValue.split(':')
|
||||
timeValue.value = inputValue
|
||||
emit('update:modelValue', inputValue)
|
||||
}
|
||||
|
||||
const validateAndCorrect = () => {
|
||||
let [hours, minutes] = timeValue.value.split(':')
|
||||
|
||||
// Validate hours and minutes
|
||||
hours = Math.min(parseInt(hours) || 0, 23)
|
||||
minutes = Math.min(parseInt(minutes) || 0, 59)
|
||||
|
||||
// Update the value with correct format
|
||||
this.timeValue = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
|
||||
this.$emit('input', this.timeValue)
|
||||
},
|
||||
timeValue.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
|
||||
emit('update:modelValue', timeValue.value)
|
||||
}
|
||||
|
||||
return {
|
||||
timeValue,
|
||||
updateValues,
|
||||
validateAndCorrect,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timer-input {
|
||||
border: 1px solid rgb(222 226 230);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
86
admin/src/components/input/ValidatedInput.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<LabeledInput
|
||||
v-bind="$attrs"
|
||||
:min="minValue"
|
||||
:max="maxValue"
|
||||
:model-value="model"
|
||||
:reset-value="resetValue"
|
||||
:locale="$i18n.locale"
|
||||
:required="!isOptional"
|
||||
:label="label"
|
||||
:name="name"
|
||||
:state="valid"
|
||||
@update:model-value="updateValue"
|
||||
>
|
||||
<BFormInvalidFeedback v-if="errorMessage">
|
||||
{{ errorMessage }}
|
||||
</BFormInvalidFeedback>
|
||||
</LabeledInput>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import LabeledInput from './LabeledInput'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: [String, Number, Date],
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
rules: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const model = ref(props.modelValue)
|
||||
|
||||
const valid = computed(() => {
|
||||
if (
|
||||
(props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) &&
|
||||
isOptional.value
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return props.rules.isValidSync(props.modelValue)
|
||||
})
|
||||
const errorMessage = computed(() => {
|
||||
if (props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
props.rules.validateSync(props.modelValue)
|
||||
return undefined
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const updateValue = (newValue) => {
|
||||
emit('update:modelValue', newValue, props.name, valid.value)
|
||||
}
|
||||
|
||||
// update model and if value changed and model isn't null, check validation,
|
||||
// for loading Input with existing value and show correct validation state
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
model.value = props.modelValue
|
||||
},
|
||||
)
|
||||
|
||||
// extract additional parameter like min and max from schema
|
||||
const schemaDescription = computed(() => props.rules.describe())
|
||||
const getTestParameter = (name) =>
|
||||
schemaDescription.value?.tests?.find((t) => t.name === name)?.params[name]
|
||||
const minValue = computed(() => getTestParameter('min'))
|
||||
const maxValue = computed(() => getTestParameter('max'))
|
||||
const resetValue = computed(() => schemaDescription.value.default)
|
||||
const isOptional = computed(() => schemaDescription.value.optional)
|
||||
</script>
|
||||