Merge branch 'master' into fix_clear_yarn_cache

This commit is contained in:
einhornimmond 2024-09-16 06:31:08 +02:00 committed by GitHub
commit e208f80345
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
418 changed files with 31996 additions and 46002 deletions

View File

@ -44,14 +44,14 @@ 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

View File

@ -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'

View File

@ -72,14 +72,14 @@ jobs:
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

View File

@ -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

View File

@ -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

View File

@ -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',

2
admin/.gitignore vendored
View File

@ -10,3 +10,5 @@ coverage/
# emacs
*~
components.d.ts

View File

@ -1 +1 @@
v14.17.0
v18.20

View File

@ -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'],
},
],
}

View File

@ -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

View File

@ -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
View 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>

View File

@ -1,39 +0,0 @@
module.exports = {
verbose: true,
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.{js,vue}',
'!**/node_modules/**',
'!src/assets/**',
'!**/?(*.)+(spec|test).js?(x)',
],
coverageThreshold: {
global: {
lines: 95,
},
},
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)',
'/node_modules/(?!@babel)',
],
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
}

View File

@ -1,6 +1,6 @@
{
"name": "admin",
"description": "Administraion Interface for Gradido",
"description": "Administration Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "2.3.1",
@ -8,82 +8,86 @@
"private": false,
"scripts": {
"start": "node run/server.js",
"serve": "vue-cli-service serve --open",
"build": "vue-cli-service build",
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"postbuild": "find build -type f -regex '.*\\.\\(html\\|js\\|css\\|svg\\|json\\)' -exec gzip -9 -k {} +",
"dev": "yarn run serve",
"analyse-bundle": "yarn build && webpack-bundle-analyzer build/webpack.stats.json",
"lint": "eslint --max-warnings=0 --ext .js,.vue,.json .",
"stylelint": "stylelint --max-warnings=0 '**/*.{scss,vue}'",
"test": "cross-env TZ=UTC jest",
"test:debug": "node --inspect-brk node_modules/.bin/vue-cli-service test:unit --no-cache --watch --runInBand",
"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.23.2",
"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.4.31",
"vue-apollo": "3.1.2",
"vue-i18n": "9.13.1",
"vue-router": "4.4.0",
"vuex": "4.1.0",
"vuex-persistedstate": "4.1.0"
},
"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",
"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",
"vitest": "^2.0.5",
"vitest-canvas-mock": "^0.3.3"
},
"browserslist": [
"> 1%",
@ -94,5 +98,10 @@
"ignore": [
"**/*.spec.js"
]
},
"resolutions": {
"strip-ansi": "6.0.1",
"string-width": "4.2.2",
"wrap-ansi": "7.0.0"
}
}

View File

@ -4,7 +4,7 @@ const path = require('path')
// Host & Port
const hostname = '127.0.0.1'
const port = process.env.PORT || 8080
const port = import.meta.env.PORT || 8080
// Express Server
const app = express()

View File

@ -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)
})
})

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

View File

@ -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()
})
})
})

View File

@ -1,124 +1,100 @@
<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',
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.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>

View File

@ -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')
})
})
})

View File

@ -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>

View File

@ -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)
})
})

View File

@ -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>

View File

@ -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({})
})
})
})

View File

@ -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

View File

@ -1,141 +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,
},
})
wrapper.vm.onReset()
})
expect(wrapper.vm.form).toEqual({
amount: null,
cycle: 'ONCE',
validTo: null,
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,
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')
})
})
})

View File

@ -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,176 @@
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')">
</BButton>
<BButton @click.prevent="emit('close-contribution-form')">
{{ $t('close') }}
</b-button>
</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'
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 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 formatDateFromDateTime = (datetimeString) => {
if (!datetimeString || !datetimeString?.includes('T')) return datetimeString
return datetimeString.split('T')[0]
}
const onReset = () => {
form.value = { validFrom: null, validTo: null }
}
defineExpose({
form,
min,
cycle,
maxPerCycle,
onSubmit,
})
</script>

View File

@ -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 :(')
})
})
})

View File

@ -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>

View File

@ -1,247 +1,161 @@
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: () => ({
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!')
})
})

View File

@ -1,244 +1,232 @@
<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>
</BFormCheckbox>
</BFormGroup>
<BFormGroup v-if="showResubmissionDate">
<BFormInput v-model="resubmissionDate" type="date" :min="now"></BFormInput>
<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>
</BFormGroup>
<BTabs v-model="tabindex" 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>
></BFormTextarea>
</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>
></BFormTextarea>
</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
></BFormTextarea>
</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 { useMutation } from '@vue/apollo-composable'
import { useI18n } from 'vue-i18n'
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 { 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 disabled = computed(() => {
return (
(tabindex.value === 0 && form.value.text === '') ||
loading.value ||
(tabindex.value === 1 && form.value.memo.length < 5) ||
(showResubmissionDate.value && !resubmissionDate.value)
)
})
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
}
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()
},
},
toastSuccess(t('message.request'))
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>

View File

@ -1,170 +1,167 @@
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,
},
],
},
}
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: {
contributionId: 42,
contributionMemo: 'test memo',
contributionUserId: 108,
contributionStatus: 'PENDING',
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()
})
describe('test reload-contribution', () => {
beforeEach(() => {
wrapper.vm.reloadContribution(3)
})
it('renders the component', () => {
expect(wrapper.find('.contribution-messages-list').exists()).toBe(true)
})
it('emits reload-contribution', () => {
expect(wrapper.emitted('reload-contribution')).toBeTruthy()
expect(wrapper.emitted('reload-contribution')[0]).toEqual([3])
})
})
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)
})
describe('test update-contributions', () => {
beforeEach(() => {
wrapper.vm.updateContributions()
})
it('renders the ContributionMessagesFormular when status is PENDING', () => {
expect(wrapper.find('contribution-messages-formular-stub').exists()).toBe(true)
})
it('emits update-contributions', () => {
expect(wrapper.emitted('update-contributions')).toBeTruthy()
})
})
it('does not render the ContributionMessagesFormular when status is not PENDING or IN_PROGRESS', async () => {
await wrapper.setProps({ contributionStatus: 'COMPLETED' })
expect(wrapper.find('contribution-messages-formular-stub').exists()).toBe(false)
})
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 update-status event', async () => {
await wrapper.vm.updateStatus(4)
expect(wrapper.emitted('update-status')).toBeTruthy()
expect(wrapper.emitted('update-status')[0]).toEqual([4])
})
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 event', async () => {
await wrapper.vm.updateContributions()
expect(wrapper.emitted('update-contributions')).toBeTruthy()
})
})

View File

@ -1,20 +1,20 @@
<template>
<div class="contribution-messages-list">
<b-container>
<div v-for="message in messages" v-bind:key="message.id">
<BContainer>
<div v-for="message in messages" :key="message.id">
<contribution-messages-list-item
:message="message"
:contributionUserId="contributionUserId"
:contribution-user-id="contributionUserId"
/>
</div>
</b-container>
</BContainer>
<div v-if="contributionStatus === 'PENDING' || contributionStatus === 'IN_PROGRESS'">
<contribution-messages-formular
:contributionId="contributionId"
:contributionMemo="contributionMemo"
:hideResubmission="hideResubmission"
:inputResubmissionDate="resubmissionAt"
@get-list-contribution-messages="$apollo.queries.Messages.refetch()"
:contribution-id="contributionId"
:contribution-memo="contributionMemo"
:hide-resubmission="hideResubmission"
:input-resubmission-date="resubmissionAt"
@get-list-contribution-messages="refetch"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@update-contributions="updateContributions"
@ -22,78 +22,74 @@
</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 } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { adminListContributionMessages } from '../../graphql/adminListContributionMessages.js'
import { useAppToast } from '@/composables/useToast'
const props = defineProps({
contributionId: {
type: Number,
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,
},
contributionMemo: {
type: String,
required: true,
},
data() {
return {
messages: [],
}
contributionStatus: {
type: String,
required: true,
},
apollo: {
Messages: {
query() {
return adminListContributionMessages
},
variables() {
return {
contributionId: this.contributionId,
}
},
fetchPolicy: 'no-cache',
update({ adminListContributionMessages }) {
this.messages = adminListContributionMessages.messages
},
error({ message }) {
this.toastError(message)
},
},
contributionUserId: {
type: Number,
required: true,
},
methods: {
updateStatus(id) {
this.$emit('update-status', id)
},
reloadContribution(id) {
this.$emit('reload-contribution', id)
},
updateContributions() {
this.$emit('update-contributions')
},
hideResubmission: {
type: Boolean,
required: true,
},
resubmissionAt: {
type: String,
required: false,
},
})
const emit = defineEmits(['update-status', 'reload-contribution', 'update-contributions'])
const { toastError } = useAppToast()
const messages = ref([])
const { onResult, onError, result, refetch } = useQuery(
adminListContributionMessages,
{
contributionId: props.contributionId,
},
{
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')
}
</script>
<style scoped>

View File

@ -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() })

View File

@ -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')
})
})
})

View File

@ -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" 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" 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>

View File

@ -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')
})
})

View File

@ -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>

View File

@ -1,139 +1,100 @@
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 { describe, it, expect, beforeEach, vi } from 'vitest'
import CreationTransactionList from './CreationTransactionList.vue'
import { useQuery } from '@vue/apollo-composable'
import { adminListContributions } from '../graphql/adminListContributions'
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(adminListContributions)
expect(call[1]).toEqual(
expect.objectContaining({
currentPage: expect.any(Number),
pageSize: expect.any(Number),
order: 'DESC',
userId: 1,
}),
)
})
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')
})
})

View File

@ -1,118 +1,113 @@
<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>
<script setup>
import { ref, watch } from 'vue'
import { useQuery } from '@vue/apollo-composable'
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)
},
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(adminListContributions, {
currentPage: currentPage.value,
pageSize: perPage.value,
order: 'DESC',
userId: props.userId,
})
watch(result, (newResult) => {
if (newResult && newResult.adminListContributions) {
rows.value = newResult.adminListContributions.contributionCount
items.value = newResult.adminListContributions.contributionList
}
})
watch(currentPage, () => {
refetch()
})
</script>

View File

@ -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')
})
})
})

View File

@ -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>

View File

@ -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
})
})

View File

@ -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>

View File

@ -1,357 +1,660 @@
import { createMockClient } from 'mock-apollo-client'
// 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 VueApollo from 'vue-apollo'
import Vuex from 'vuex'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createStore } from 'vuex'
import CommunityVisualizeItem from './CommunityVisualizeItem.vue'
import { updateHomeCommunity } from '../../graphql/updateHomeCommunity'
import { toastSuccessSpy } from '../../../test/testSetup'
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'
const mockClient = createMockClient()
const apolloProvider = new VueApollo({
defaultClient: mockClient,
})
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 localVue = global.localVue
localVue.use(Vuex)
localVue.use(VueApollo)
const today = new Date()
const createdDate = new Date()
const createdDate = new Date(today)
createdDate.setDate(createdDate.getDate() - 3)
// Mock für den Vuex-Store
const store = new Vuex.Store({
state: {
moderator: {
roles: ['ADMIN'],
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,
})
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
let store
let mockI18n
const updateHomeCommunityMock = jest.fn()
mockClient.setRequestHandler(
updateHomeCommunity,
updateHomeCommunityMock.mockResolvedValue({
data: {
updateHomeCommunity: { id: 1 },
const createWrapper = (props = {}, locale = 'en') => {
store = createStore({
state: {
moderator: {
roles: ['ADMIN'],
},
},
}),
)
})
const Wrapper = () => {
return mount(CommunityVisualizeItem, { localVue, mocks, propsData, store, apolloProvider })
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': {},
},
},
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
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('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('has the public key', () => {
expect(wrapper.find('.row > div:nth-child(2) > small').text()).toContain(
'4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2'.substring(0, 26),
)
})
it('toggles details on click', async () => {
// Click the row to toggle details
await wrapper.find('.row').trigger('click')
describe('verified item', () => {
it('has the check icon', () => {
expect(wrapper.find('variant-icon-stub[icon="check"]').exists()).toBe(true)
})
// 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)
it('has the text variant "success"', () => {
expect(wrapper.find('variant-icon-stub[variant="success"]').exists()).toBe(true)
})
})
describe('rendering item properties', () => {
it('has the url', () => {
expect(wrapper.find('.row > div:nth-child(2) > div > a').text()).toBe(
'http://localhost/api/',
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('has the public key', () => {
expect(wrapper.find('.row > div:nth-child(2) > small').text()).toContain(
'4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2'.substring(0, 26),
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)
})
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')
})
})
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)
})
})
})

View File

@ -1,38 +1,42 @@
<template>
<div class="community-visualize-item">
<b-row @click="toggleDetails">
<b-col cols="1"><b-icon :icon="icon" :variant="variant" class="mr-4"></b-icon></b-col>
<b-col>
<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>
</b-col>
<b-col v-b-tooltip="item.description">{{ item.name }}</b-col>
<b-col cols="2">{{ lastAnnouncedAt }}</b-col>
<b-col cols="2">{{ createdAt }}</b-col>
</b-row>
<b-row v-if="details" class="details">
<b-col colspan="5">
<b-list-group>
<b-list-group-item v-if="item.uuid">
</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') }}&nbsp;{{ item.uuid }}
</b-list-group-item>
<b-list-group-item v-if="item.authenticatedAt">
</BListGroupItem>
<BListGroupItem v-if="item.authenticatedAt">
{{ $t('federation.authenticatedAt') }}&nbsp;{{ item.authenticatedAt }}
</b-list-group-item>
<b-list-group-item>
</BListGroupItem>
<BListGroupItem>
{{ $t('federation.publicKey') }}&nbsp;{{ item.publicKey }}
</b-list-group-item>
<b-list-group-item v-if="!item.foreign">
</BListGroupItem>
<BListGroupItem v-if="!item.foreign">
<editable-group
:allowEdit="$store.state.moderator.roles.includes('ADMIN')"
:allow-edit="$store.state.moderator.roles.includes('ADMIN')"
@save="handleUpdateHomeCommunity"
@reset="resetHomeCommunityEditable"
>
<template #view>
<label>{{ $t('federation.gmsApiKey') }}&nbsp;{{ gmsApiKey }}</label>
<b-form-group>
<BFormGroup>
{{ $t('federation.coordinates') }}
<span v-if="isValidLocation">
{{
@ -42,166 +46,283 @@
})
}}
</span>
</b-form-group>
</BFormGroup>
</template>
<template #edit>
<editable-groupable-label
v-model="gmsApiKey"
:label="$t('federation.gmsApiKey')"
idName="home-community-api-key"
id-name="home-community-api-key"
/>
<coordinates v-model="location" />
</template>
</editable-group>
</b-list-group-item>
<b-list-group-item>
<b-list-group>
<b-row>
<b-col class="ml-1">{{ $t('federation.verified') }}</b-col>
<b-col>{{ $t('federation.apiVersion') }}</b-col>
<b-col>{{ $t('federation.createdAt') }}</b-col>
<b-col>{{ $t('federation.lastAnnouncedAt') }}</b-col>
<b-col>{{ $t('federation.verifiedAt') }}</b-col>
<b-col>{{ $t('federation.lastErrorAt') }}</b-col>
</b-row>
<b-list-group-item
</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" />
</b-list-group-item>
</b-list-group>
</b-list-group-item>
</b-list-group>
</b-col>
</b-row>
</BListGroup-item>
</BListGroup>
</BListGroup-item>
</BListGroup>
</BCol>
</BRow>
</div>
</template>
<script>
<script setup>
import { ref, computed, toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMutation } from '@vue/apollo-composable'
import { formatDistanceToNow } from 'date-fns'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
import EditableGroup from '@/components/input/EditableGroup'
import EditableGroup from '@/components/input/EditableGroup.vue'
import FederationVisualizeItem from './FederationVisualizeItem.vue'
import { updateHomeCommunity } from '../../graphql/updateHomeCommunity'
import { updateHomeCommunity } from '@/graphql/updateHomeCommunity'
import Coordinates from '../input/Coordinates.vue'
import EditableGroupableLabel from '../input/EditableGroupableLabel.vue'
import { useAppToast } from '@/composables/useToast'
const locales = { en, de, es, fr, nl }
export default {
name: 'CommunityVisualizeItem',
components: {
Coordinates,
EditableGroup,
FederationVisualizeItem,
EditableGroupableLabel,
},
props: {
item: { type: Object },
},
data() {
return {
formatDistanceToNow,
locale: this.$i18n.locale,
details: false,
gmsApiKey: this.item.gmsApiKey,
location: this.item.location,
originalGmsApiKey: this.item.gmsApiKey,
originalLocation: this.item.location,
const props = defineProps({
item: { type: Object, required: true },
})
const { item } = toRefs(props)
const { t, locale } = 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: locales[locale.value],
})
}
return ''
})
const createdAt = computed(() => {
if (item.value.createdAt) {
return formatDistanceToNow(new Date(item.value.createdAt), {
includeSecond: true,
addSuffix: true,
locale: locales[locale.value],
})
}
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'))
}
},
computed: {
verified() {
if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) {
return false
}
return (
this.item.federatedCommunities.filter(
(federatedCommunity) =>
new Date(federatedCommunity.verifiedAt) >= new Date(federatedCommunity.lastAnnouncedAt),
).length > 0
)
},
icon() {
return this.verified ? 'check' : 'x-circle'
},
variant() {
return this.verified ? 'success' : 'danger'
},
lastAnnouncedAt() {
if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) return ''
const minDate = new Date(0)
const lastAnnouncedAt = this.item.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: 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 ''
},
isLocationChanged() {
return this.originalLocation !== this.location
},
isGMSApiKeyChanged() {
return this.originalGmsApiKey !== this.gmsApiKey
},
isValidLocation() {
return this.location && this.location.latitude && this.location.longitude
},
},
methods: {
toggleDetails() {
this.details = !this.details
},
handleUpdateHomeCommunity() {
this.$apollo
.mutate({
mutation: updateHomeCommunity,
variables: {
uuid: this.item.uuid,
gmsApiKey: this.gmsApiKey,
location: this.location,
},
})
.then(() => {
if (this.isLocationChanged && this.isGMSApiKeyChanged) {
this.toastSuccess(this.$t('federation.toast_gmsApiKeyAndLocationUpdated'))
} else if (this.isGMSApiKeyChanged) {
this.toastSuccess(this.$t('federation.toast_gmsApiKeyUpdated'))
} else if (this.isLocationChanged) {
this.toastSuccess(this.$t('federation.toast_gmsLocationUpdated'))
}
this.originalLocation = this.location
this.originalGmsApiKey = this.gmsApiKey
})
.catch((error) => {
this.toastError(error.message)
})
},
resetHomeCommunityEditable() {
this.location = this.originalLocation
this.gmsApiKey = this.originalGmsApiKey
},
},
originalLocation.value = location.value
originalGmsApiKey.value = gmsApiKey.value
} catch (error) {
toastError(error.message)
}
}
const resetHomeCommunityEditable = () => {
location.value = originalLocation.value
gmsApiKey.value = originalGmsApiKey.value
}
</script>
<!--<script>-->
<!--import { formatDistanceToNow } from 'date-fns'-->
<!--import { de, enUS as en, fr, es, nl } from 'date-fns/locale'-->
<!--import EditableGroup from '@/components/input/EditableGroup'-->
<!--import FederationVisualizeItem from './FederationVisualizeItem.vue'-->
<!--import { updateHomeCommunity } from '../../graphql/updateHomeCommunity'-->
<!--import Coordinates from '../input/Coordinates.vue'-->
<!--import EditableGroupableLabel from '../input/EditableGroupableLabel.vue'-->
<!--const locales = { en, de, es, fr, nl }-->
<!--export default {-->
<!-- name: 'CommunityVisualizeItem',-->
<!-- components: {-->
<!-- Coordinates,-->
<!-- EditableGroup,-->
<!-- FederationVisualizeItem,-->
<!-- EditableGroupableLabel,-->
<!-- },-->
<!-- props: {-->
<!-- item: { type: Object },-->
<!-- },-->
<!-- data() {-->
<!-- return {-->
<!-- formatDistanceToNow,-->
<!-- locale: this.$i18n.locale,-->
<!-- details: false,-->
<!-- gmsApiKey: this.item.gmsApiKey,-->
<!-- location: this.item.location,-->
<!-- originalGmsApiKey: this.item.gmsApiKey,-->
<!-- originalLocation: this.item.location,-->
<!-- }-->
<!-- },-->
<!-- computed: {-->
<!-- verified() {-->
<!-- if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) {-->
<!-- return false-->
<!-- }-->
<!-- return (-->
<!-- this.item.federatedCommunities.filter(-->
<!-- (federatedCommunity) =>-->
<!-- new Date(federatedCommunity.verifiedAt) >= new Date(federatedCommunity.lastAnnouncedAt),-->
<!-- ).length > 0-->
<!-- )-->
<!-- },-->
<!-- icon() {-->
<!-- return this.verified ? 'check' : 'x-circle'-->
<!-- },-->
<!-- variant() {-->
<!-- return this.verified ? 'success' : 'danger'-->
<!-- },-->
<!-- lastAnnouncedAt() {-->
<!-- if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) return ''-->
<!-- const minDate = new Date(0)-->
<!-- const lastAnnouncedAt = this.item.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: 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 ''-->
<!-- },-->
<!-- isLocationChanged() {-->
<!-- return this.originalLocation !== this.location-->
<!-- },-->
<!-- isGMSApiKeyChanged() {-->
<!-- return this.originalGmsApiKey !== this.gmsApiKey-->
<!-- },-->
<!-- isValidLocation() {-->
<!-- return this.location && this.location.latitude && this.location.longitude-->
<!-- },-->
<!-- },-->
<!-- methods: {-->
<!-- toggleDetails() {-->
<!-- this.details = !this.details-->
<!-- },-->
<!-- handleUpdateHomeCommunity() {-->
<!-- this.$apollo-->
<!-- .mutate({-->
<!-- mutation: updateHomeCommunity,-->
<!-- variables: {-->
<!-- uuid: this.item.uuid,-->
<!-- gmsApiKey: this.gmsApiKey,-->
<!-- location: this.location,-->
<!-- },-->
<!-- })-->
<!-- .then(() => {-->
<!-- if (this.isLocationChanged && this.isGMSApiKeyChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsApiKeyAndLocationUpdated'))-->
<!-- } else if (this.isGMSApiKeyChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsApiKeyUpdated'))-->
<!-- } else if (this.isLocationChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsLocationUpdated'))-->
<!-- }-->
<!-- this.originalLocation = this.location-->
<!-- this.originalGmsApiKey = this.gmsApiKey-->
<!-- })-->
<!-- .catch((error) => {-->
<!-- this.toastError(error.message)-->
<!-- })-->
<!-- },-->
<!-- resetHomeCommunityEditable() {-->
<!-- this.location = this.originalLocation-->
<!-- this.gmsApiKey = this.originalGmsApiKey-->
<!-- },-->
<!-- },-->
<!--}-->
<!--</script>-->

View File

@ -1,23 +1,43 @@
<template>
<div class="federation-visualize-item">
<b-row>
<b-col><b-icon :icon="icon" :variant="variant" class="mr-4"></b-icon></b-col>
<b-col class="ml-1">{{ item.apiVersion }}</b-col>
<b-col v-b-tooltip="item.createdAt">{{ distanceDate(item.createdAt) }}</b-col>
<b-col v-b-tooltip="item.lastAnnouncedAt">{{ distanceDate(item.lastAnnouncedAt) }}</b-col>
<b-col v-b-tooltip="item.verifiedAt">{{ distanceDate(item.verifiedAt) }}</b-col>
<b-col v-b-tooltip="item.lastErrorAt">{{ distanceDate(item.lastErrorAt) }}</b-col>
</b-row>
<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 { de, enUS as en, fr, es, nl } from 'date-fns/locale'
import VariantIcon from '@/components/VariantIcon.vue'
const locales = { en, de, es, fr, nl }
export default {
name: 'FederationVisualizeItem',
components: { VariantIcon },
props: {
item: { type: Object },
},

View File

@ -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')
})
})

View File

@ -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>

View File

@ -1,124 +1,130 @@
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(7)
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('/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.replace('{token}', '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')
})
})
})

View File

@ -1,59 +1,102 @@
<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="/statistic" :active="isActive('statistic')">
{{ $t('navbar.statistic') }}
</BNavItem>
<BNavItem @click="handleWallet">{{ $t('navbar.my-account') }}</BNavItem>
<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,
} 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.replace('{token}', 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>

View File

@ -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)
})
})

View File

@ -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);
}

View File

@ -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)
})
})

View File

@ -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>

View File

@ -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>

View File

@ -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')
})
})

View File

@ -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,66 +37,66 @@
</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>
</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)"
>
@ -102,18 +106,18 @@
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-id="row.item.id"
:contribution-status="row.item.status"
:contribution-user-id="row.item.userId"
:contribution-memo="row.item.memo"
:resubmission-at="row.item.resubmissionAt"
:hide-resubmission="hideResubmission"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@update-contributions="updateContributions"
@ -122,12 +126,11 @@
</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,14 @@ export default {
required: false,
},
},
emits: ['update-contributions', 'reload-contribution', 'update-status', 'show-overlay'],
data() {
return {
slotIndex: 0,
openRow: null,
creationUserData: {},
}
},
methods: {
myself(item) {
return item.userId === this.$store.state.moderator.id
@ -190,6 +200,29 @@ export default {
updateContributions() {
this.$emit('update-contributions')
},
rowToggleDetails(row, index) {
if (this.openRow) {
if (this.openRow.index === row.index) {
if (index === this.slotIndex) {
row.toggleDetails()
this.openRow = null
} else {
this.slotIndex = index
}
} else {
this.openRow.toggleDetails()
row.toggleDetails()
this.slotIndex = index
this.openRow = row
this.creationUserData = row.item
}
} else {
row.toggleDetails()
this.slotIndex = index
this.openRow = row
this.creationUserData = row.item
}
},
},
}
</script>

View File

@ -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])
})
})
})

View File

@ -1,6 +1,6 @@
<template>
<div class="search-user-table">
<b-table
<BTable
tbody-tr-class="pointer"
:items="myItems"
:fields="fields"
@ -9,151 +9,287 @@
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',
USER: 'USER',
}
const userChangeForm = ref()
const deletedUserForm = ref()
const undeletedUserForm = ref()
const myItems = ref()
const creationUserData = ref({})
const rowDetails = ref()
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: t('overlay.changeUserRole.question', {
username: `${selectedRow.value.firstName} ${selectedRow.value.lastName}`,
newRole:
userChangeForm.value.roleSelected === rolesValues.ADMIN
? t('userRole.selectRoles.admin')
: userChangeForm.value.roleSelected === rolesValues.MODERATOR
? t('userRole.selectRoles.moderator')
: t('userRole.selectRoles.user'),
}),
},
})
.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>

View File

@ -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')
})
})

View File

@ -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>

View File

@ -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()
})
})

View File

@ -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>

View File

@ -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)
})
})

View File

@ -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>

View 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>

View File

@ -1,37 +1,41 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import Coordinates from './Coordinates.vue'
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
Vue.use(VueI18n)
const localVue = global.localVue
const mocks = {
$t: jest.fn((t, v) => {
if (t === 'geo-coordinates.format') {
return `${v.latitude}, ${v.longitude}`
}
return t
}),
const value = {
latitude: 56.78,
longitude: 12.34,
}
describe('Coordinates', () => {
let wrapper
const value = {
latitude: 56.78,
longitude: 12.34,
}
const createWrapper = (propsData) => {
const createWrapper = (props = {}) => {
return mount(Coordinates, {
localVue,
mocks,
propsData,
props: {
value,
...props,
},
global: {
mocks: {
$t: vi.fn((t, v) => {
if (t === 'geo-coordinates.format') {
return `${v.latitude}, ${v.longitude}`
}
return t
}),
},
stubs: {
BFormGroup,
BFormInput,
},
},
})
}
beforeEach(() => {
wrapper = createWrapper({ value })
wrapper = createWrapper()
})
it('renders the component with initial values', () => {
@ -51,7 +55,7 @@ describe('Coordinates', () => {
expect(wrapper.vm.inputValue).toStrictEqual({
latitude: 34.56,
longitude: 78.9,
longitude: '78.90',
})
})
@ -59,18 +63,18 @@ describe('Coordinates', () => {
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.emitted().input[0][0]).toEqual({
await latitudeInput.setValue(34.56)
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0][0]).toEqual({
latitude: 34.56,
longitude: 12.34,
})
await longitudeInput.setValue('78.90')
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[1][0]).toEqual({
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[1][0]).toEqual({
latitude: 34.56,
longitude: 78.9,
longitude: '78.90',
})
})
@ -80,6 +84,8 @@ describe('Coordinates', () => {
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,

View File

@ -1,39 +1,39 @@
<template>
<div>
<b-form-group
<BFormGroup
:label="$t('geo-coordinates.label')"
:invalid-feedback="$t('geo-coordinates.both-or-none')"
:state="isValid"
>
<b-form-group
<BFormGroup
:label="$t('latitude-longitude-smart')"
label-for="home-community-latitude-longitude-smart"
:description="$t('geo-coordinates.latitude-longitude-smart.describe')"
>
<b-form-input
v-model="locationString"
<BFormInput
id="home-community-latitude-longitude-smart"
v-model="locationString"
type="text"
@input="splitCoordinates"
/>
</b-form-group>
<b-form-group :label="$t('latitude')" label-for="home-community-latitude">
<b-form-input
v-model="inputValue.latitude"
</BFormGroup>
<BFormGroup :label="$t('latitude')" label-for="home-community-latitude">
<BFormInput
id="home-community-latitude"
v-model="inputValue.latitude"
type="text"
@input="valueUpdated"
/>
</b-form-group>
<b-form-group :label="$t('longitude')" label-for="home-community-longitude">
<b-form-input
v-model="inputValue.longitude"
</BFormGroup>
<BFormGroup :label="$t('longitude')" label-for="home-community-longitude">
<BFormInput
id="home-community-longitude"
v-model="inputValue.longitude"
type="text"
@input="valueUpdated"
/>
</b-form-group>
</b-form-group>
</BFormGroup>
</BFormGroup>
</div>
</template>
@ -41,9 +41,12 @@
export default {
name: 'Coordinates',
props: {
value: Object,
default: null,
value: {
type: Object,
default: null,
},
},
emits: ['input'],
data() {
return {
inputValue: this.value,
@ -66,7 +69,7 @@ export default {
methods: {
splitCoordinates(value) {
// default format for geo-coordinates: 'latitude, longitude'
const parts = value.split(',').map((part) => part.trim())
const parts = this.locationString.split(',').map((part) => part.trim())
if (parts.length === 2) {
const [lat, lon] = parts
@ -93,7 +96,7 @@ export default {
getLatitudeLongitudeString({ latitude, longitude } = {}) {
return latitude && longitude ? this.$t('geo-coordinates.format', { latitude, longitude }) : ''
},
valueUpdated(value) {
valueUpdated() {
this.locationString = this.getLatitudeLongitudeString(this.inputValue)
this.inputValue = this.sanitizeLocation(this.inputValue)

View File

@ -1,22 +1,25 @@
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 localVue = global.localVue
const viewValue = 'test label value'
const editValue = 'test edit value'
const mocks = {
$t: jest.fn((t) => t),
}
describe('EditableGroup', () => {
let wrapper
const createWrapper = (propsData) => {
const createWrapper = (props = {}) => {
return mount(EditableGroup, {
localVue,
propsData,
mocks,
props,
global: {
mocks: {
$t: (key) => key,
},
stubs: {
BFormGroup,
BButton,
IBiPencilFill: true,
},
},
slots: {
view: `<div>${viewValue}</div>`,
edit: `<div class='test-edit'>${editValue}</div>`,
@ -25,68 +28,52 @@ describe('EditableGroup', () => {
}
it('renders the view slot when not editing', () => {
wrapper = createWrapper({ allowEdit: true })
expect(wrapper.find('div').text()).toBe(viewValue)
const wrapper = createWrapper({ allowEdit: true })
expect(wrapper.text()).toContain(viewValue)
})
it('renders the edit slot when editing', async () => {
wrapper = createWrapper({ allowEdit: true })
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 () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
await wrapper.vm.$emit('input', 'New Value') // Simulate input change
await wrapper.setData({ isValueChanged: true }) // Set valueChanged to true
await wrapper.find('button').trigger('click') // Click to save
expect(wrapper.emitted().save).toBeTruthy()
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 () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
expect(wrapper.find('button').attributes('disabled')).toBe('disabled')
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 () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
await wrapper.vm.$emit('input', 'New Value') // Simulate input change
await wrapper.setData({ isValueChanged: true }) // Set valueChanged to true
expect(wrapper.find('button').attributes('disabled')).toBeFalsy()
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 () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
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', async () => {
wrapper = createWrapper({ allowEdit: true })
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 () => {
wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click') // Click to enable editing
await wrapper.find('button.close-button').trigger('click') // Click close button
expect(wrapper.emitted().reset).toBeTruthy()
const wrapper = createWrapper({ allowEdit: true })
await wrapper.find('button').trigger('click')
await wrapper.find('.close-button').trigger('click')
expect(wrapper.emitted('reset')).toBeTruthy()
})
})

View File

@ -1,20 +1,21 @@
<template>
<div>
<slot v-if="!isEditing" v-bind:isEditing="isEditing" name="view"></slot>
<slot v-else v-bind:isEditing="isEditing" name="edit" @input="valueChanged"></slot>
<b-form-group v-if="allowEdit && !isEditing">
<b-button @click="enableEdit" :variant="variant">
<b-icon icon="pencil-fill">{{ $t('edit') }}</b-icon>
</b-button>
</b-form-group>
<b-form-group v-else-if="allowEdit && isEditing">
<b-button @click="save" :variant="variant" :disabled="!isValueChanged" class="save-button">
<slot v-if="!isEditing" :is-editing="isEditing" name="view"></slot>
<slot v-else :is-editing="isEditing" name="edit" @input="valueChanged"></slot>
<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') }}
</b-button>
<b-button @click="close" variant="secondary" class="close-button">
</BButton>
<BButton variant="secondary" class="close-button" @click="close">
{{ $t('close') }}
</b-button>
</b-form-group>
</BButton>
</BFormGroup>
</div>
</template>
@ -27,6 +28,7 @@ export default {
default: false,
},
},
emits: ['save', 'reset'],
data() {
return {
isEditing: false,

View File

@ -1,7 +1,8 @@
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 localVue = global.localVue
const value = 'test label value'
const label = 'Test Label'
const idName = 'test-id-name'
@ -9,70 +10,94 @@ const idName = 'test-id-name'
describe('EditableGroupableLabel', () => {
let wrapper
const createWrapper = (propsData) => {
return mount(EditableGroupableLabel, {
localVue,
propsData,
const createWrapper = (props = {}, parentMethods = {}) => {
const Parent = {
template: '<editable-groupable-label v-bind="$props" />',
components: {
EditableGroupableLabel,
},
props: ['value', 'label', 'idName'],
methods: {
onInput: vi.fn(),
...parentMethods,
},
}
return mount(Parent, {
props: {
value,
label,
idName,
...props,
},
global: {
stubs: {
BFormGroup,
BFormInput,
},
},
})
}
beforeEach(() => {
wrapper = createWrapper({ value, label, idName })
wrapper = createWrapper()
})
it('renders the label correctly', () => {
expect(wrapper.find('label').text()).toBe(label)
it('renders the component', () => {
expect(wrapper.exists()).toBe(true)
})
it('renders the input with the correct id and value', () => {
const input = wrapper.find('input')
expect(input.attributes('id')).toBe(idName)
expect(input.element.value).toBe(value)
it('renders BFormGroup with correct props', () => {
const formGroup = wrapper.findComponent(BFormGroup)
expect(formGroup.props('label')).toBe(label)
expect(formGroup.props('labelFor')).toBe(idName)
})
it('emits input event with the correct value when input changes', async () => {
const newValue = 'new label value'
const input = wrapper.find('input')
input.element.value = newValue
await input.trigger('input')
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[0][0]).toBe(newValue)
it('renders BFormInput with correct props', () => {
const formInput = wrapper.findComponent({ name: 'BFormInput' })
expect(formInput.props('id')).toBe(idName)
expect(formInput.props('modelValue')).toBe(value)
})
it('calls valueChanged method on parent when value changes', async () => {
const valueChangedMock = jest.fn()
wrapper.vm.$parent = { valueChanged: valueChangedMock }
// 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.find('input')
input.element.value = newValue
await input.trigger('input')
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', newValue)
expect(valueChangedMock).toHaveBeenCalled()
})
it('calls invalidValues method on parent when value is reverted to original', async () => {
const invalidValuesMock = jest.fn()
wrapper.vm.$parent = { invalidValues: invalidValuesMock }
it('calls parent.invalidValues when value is reverted to original', async () => {
const invalidValuesMock = vi.fn()
wrapper = createWrapper({}, { invalidValues: invalidValuesMock })
const input = wrapper.find('input')
input.element.value = 'new label value'
await input.trigger('input')
input.element.value = value
await input.trigger('input')
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', 'new label value')
await input.vm.$emit('input', value)
expect(invalidValuesMock).toHaveBeenCalled()
})
it('does not call valueChanged method on parent when value is reverted to original', async () => {
const valueChangedMock = jest.fn()
wrapper.vm.$parent = { valueChanged: valueChangedMock }
it('does not call parent.valueChanged when value is reverted to original', async () => {
const valueChangedMock = vi.fn()
wrapper = createWrapper({}, { valueChanged: valueChangedMock })
const input = wrapper.find('input')
input.element.value = value
await input.trigger('input')
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', value)
expect(valueChangedMock).not.toHaveBeenCalled()
})

View File

@ -1,7 +1,7 @@
<template>
<b-form-group :label="label" :label-for="idName">
<b-form-input :id="idName" v-model="inputValue" @input="updateValue" />
</b-form-group>
<BFormGroup :label="label" :label-for="idName">
<BFormInput :id="idName" v-model="inputValue" @input="updateValue" />
</BFormGroup>
</template>
<script>
@ -22,6 +22,7 @@ export default {
required: true,
},
},
emits: ['input'],
data() {
return {
inputValue: this.value,

View File

@ -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
},
})
@ -17,9 +18,9 @@ describe('TimePicker', () => {
// Check if timeValue is updated
expect(wrapper.vm.timeValue).toBe('23:45')
// 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('input')).toBeTruthy()
expect(wrapper.emitted('input')[0]).toEqual(['23:45'])
})
it('validates and corrects time format on blur', async () => {
@ -29,8 +30,8 @@ describe('TimePicker', () => {
// Simulate user input
await input.setValue('99:99')
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input[0]).toEqual(['99:99'])
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0]).toEqual(['99:99'])
// Trigger blur event
await input.trigger('blur')
@ -38,26 +39,26 @@ 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('input')).toBeTruthy()
expect(wrapper.emitted('input')[1]).toEqual(['23:59'])
})
it('check handling of empty input', async () => {
it('checks handling of empty input', async () => {
const wrapper = mount(TimePicker)
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('input')).toBeTruthy()
expect(wrapper.emitted('input')[1]).toEqual(['00:00'])
})
})

View File

@ -1,11 +1,11 @@
<template>
<div>
<input
type="text"
v-model="timeValue"
type="text"
placeholder="hh:mm"
@input="updateValues"
@blur="validateAndCorrect"
placeholder="hh:mm"
/>
</div>
</template>
@ -20,6 +20,7 @@ export default {
default: '00:00',
},
},
emits: ['input'],
data() {
return {
timeValue: this.value,

View File

@ -0,0 +1,70 @@
import { ref, computed, watch } from 'vue'
import { adminOpenCreations } from '../graphql/adminOpenCreations'
import { useQuery } from '@vue/apollo-composable'
import { useI18n } from 'vue-i18n'
import toast from 'bootstrap/js/src/toast'
export default () => {
const { d } = useI18n()
const creation = ref([1000, 1000, 1000])
const userId = ref(0)
const creationDates = computed(() => {
const now = new Date(Date.now())
const dates = [now]
for (let i = 1; i < 3; i++) {
dates.push(new Date(now.getFullYear(), now.getMonth() - i, 1))
}
return dates.reverse()
})
const creationDateObjects = computed(() => {
const result = []
creationDates.value.forEach((date) => {
result.push({
short: d(date, 'month'),
long: d(date, 'long'),
year: d(date, 'year'),
date: d(date, 'short', 'en'),
})
})
return result
})
const radioOptions = () => {
return creationDateObjects.value.map((obj, idx) => {
return {
item: { ...obj, creation: creation.value[idx] },
name: obj.short + (creation.value[idx] ? ' ' + creation.value[idx] + ' GDD' : ''),
}
})
}
const creationLabel = () => {
return creationDates.value.map((date) => d(date, 'monthShort')).join(' | ')
}
const { result, error } = useQuery(adminOpenCreations, { userId }, { fetchPolicy: 'no-cache' })
watch(result, (newResult) => {
if (newResult && newResult.adminOpenCreations) {
creation.value = newResult.adminOpenCreations.map((obj) => obj.amount)
}
})
watch(error, (err) => {
if (err) {
toast.error(err.message)
}
})
return {
creation,
userId,
creationDates,
creationDateObjects,
radioOptions,
creationLabel,
}
}

View File

@ -0,0 +1,41 @@
import { useI18n } from 'vue-i18n'
import { useToast } from 'bootstrap-vue-next'
export function useAppToast() {
const { t } = useI18n()
const { show } = useToast()
const toastSuccess = (message) => {
toast(message, {
title: t('success'),
variant: 'success',
})
}
const toastError = (message) => {
toast(message, {
title: t('error'),
variant: 'danger',
})
}
const toast = (message, options = {}) => {
if (message.replace) message = message.replace(/^GraphQL error: /, '')
options = {
solid: true,
toaster: 'b-toaster-top-right',
headerClass: 'gdd-toaster-title',
bodyClass: 'gdd-toaster-body',
toastClass: 'gdd-toaster',
...options,
body: message,
}
show({ props: { ...options } })
}
return {
toastSuccess,
toastError,
toast,
}
}

View File

@ -21,9 +21,9 @@ const version = {
}
const environment = {
NODE_ENV: process.env.NODE_ENV,
DEBUG: process.env.NODE_ENV !== 'production' ?? false,
PRODUCTION: process.env.NODE_ENV === 'production' ?? false,
NODE_ENV: import.meta.env.NODE_ENV,
DEBUG: import.meta.env.NODE_ENV !== 'production' ?? false,
PRODUCTION: import.meta.env.NODE_ENV === 'production' ?? false,
}
const COMMUNITY_HOST = process.env.COMMUNITY_HOST ?? undefined
@ -64,4 +64,4 @@ const CONFIG = {
...debug,
}
module.exports = CONFIG
export default CONFIG

View File

@ -1,20 +1,6 @@
import Vue from 'vue'
import VueI18n from 'vue-i18n'
Vue.use(VueI18n)
const loadLocaleMessages = () => {
const locales = require.context('./locales/', true, /[A-Za-z0-9-_,\s]+\.json$/i)
const messages = {}
locales.keys().forEach((key) => {
const matched = key.match(/([A-Za-z0-9-_]+)\./i)
if (matched && matched.length > 1) {
const locale = matched[1]
messages[locale] = locales(key)
}
})
return messages
}
import { createI18n } from 'vue-i18n'
import de from './locales/de.json'
import en from './locales/en.json'
const numberFormats = {
en: {
@ -45,7 +31,7 @@ const numberFormats = {
},
}
const dateTimeFormats = {
const datetimeFormats = {
en: {
short: {
year: 'numeric',
@ -96,12 +82,13 @@ const dateTimeFormats = {
},
}
const i18n = new VueI18n({
const i18n = createI18n({
locale: 'en',
legacy: false,
fallbackLocale: 'en',
messages: loadLocaleMessages(),
messages: { de, en },
numberFormats,
dateTimeFormats,
datetimeFormats,
})
export default i18n

View File

@ -1,29 +1,86 @@
import i18n from './i18n'
import VueI18n from 'vue-i18n'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createI18n } from 'vue-i18n'
import de from './locales/de.json'
import en from './locales/en.json'
jest.mock('vue-i18n')
vi.mock('vue-i18n')
vi.mock('./locales/de.json', () => ({ default: { test: 'Test DE' } }))
vi.mock('./locales/en.json', () => ({ default: { test: 'Test EN' } }))
describe('i18n', () => {
it('calls i18n with locale en', () => {
expect(VueI18n).toBeCalledWith(
expect.objectContaining({
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('creates i18n instance with correct configuration', async () => {
const mockCreateI18n = vi.mocked(createI18n)
mockCreateI18n.mockReturnValue({
global: {
locale: 'en',
}),
)
})
it('calls i18n with fallback locale en', () => {
expect(VueI18n).toBeCalledWith(
expect.objectContaining({
fallbackLocale: 'en',
t: vi.fn(),
d: vi.fn(),
n: vi.fn(),
},
})
const i18n = (await import('./i18n')).default
expect(mockCreateI18n).toHaveBeenCalledWith({
locale: 'en',
legacy: false,
fallbackLocale: 'en',
messages: { de, en },
numberFormats: expect.any(Object),
datetimeFormats: expect.any(Object),
})
expect(i18n.global.t).toBeDefined()
expect(i18n.global.d).toBeDefined()
expect(i18n.global.n).toBeDefined()
})
it('configures number formats correctly', async () => {
const mockCreateI18n = vi.mocked(createI18n)
await import('./i18n')
const callArg = mockCreateI18n.mock.calls[0][0]
expect(callArg.numberFormats).toEqual(
expect.objectContaining({
en: expect.objectContaining({
decimal: expect.any(Object),
ungroupedDecimal: expect.any(Object),
}),
de: expect.objectContaining({
decimal: expect.any(Object),
ungroupedDecimal: expect.any(Object),
}),
}),
)
})
it('has a _t function', () => {
expect(i18n).toEqual(
it('configures datetime formats correctly', async () => {
const mockCreateI18n = vi.mocked(createI18n)
await import('./i18n')
const callArg = mockCreateI18n.mock.calls[0][0]
expect(callArg.datetimeFormats).toEqual(
expect.objectContaining({
_t: expect.anything(),
en: expect.objectContaining({
short: expect.any(Object),
long: expect.any(Object),
monthShort: expect.any(Object),
month: expect.any(Object),
year: expect.any(Object),
}),
de: expect.objectContaining({
short: expect.any(Object),
long: expect.any(Object),
monthShort: expect.any(Object),
month: expect.any(Object),
year: expect.any(Object),
}),
}),
)
})

View File

@ -10,7 +10,7 @@
import NavBar from '@/components/NavBar'
import ContentFooter from '@/components/ContentFooter'
export default {
name: 'defaultLayout',
name: 'DefaultLayout',
components: {
NavBar,
ContentFooter,

View File

@ -1,12 +1,20 @@
import { describe, it, expect, beforeEach } from 'vitest'
import locales from './index.js'
describe('locales', () => {
it('should contain 2 locales', () => {
expect(locales).toHaveLength(2)
let localeCopy
beforeEach(() => {
localeCopy = [...locales] // Create a copy to avoid modifying the original
})
it('should contain exactly 2 locales', () => {
expect(localeCopy).toHaveLength(2)
})
it('should contain a German locale', () => {
expect(locales).toContainEqual(
const germanLocale = localeCopy.find((locale) => locale.code === 'de')
expect(germanLocale).toEqual(
expect.objectContaining({
name: 'Deutsch',
code: 'de',
@ -15,4 +23,54 @@ describe('locales', () => {
}),
)
})
it('should contain an English locale', () => {
const englishLocale = localeCopy.find((locale) => locale.code === 'en')
expect(englishLocale).toEqual(
expect.objectContaining({
name: 'English',
code: 'en',
iso: 'en-US',
enabled: true,
}),
)
})
it('should have unique code and iso values for each locale', () => {
const codes = localeCopy.map((locale) => locale.code)
const isos = localeCopy.map((locale) => locale.iso)
expect(new Set(codes).size).toBe(localeCopy.length)
expect(new Set(isos).size).toBe(localeCopy.length)
})
it('should have all locales enabled', () => {
expect(localeCopy.every((locale) => locale.enabled)).toBe(true)
})
it('should have valid ISO codes', () => {
const isoRegex = /^[a-z]{2}-[A-Z]{2}$/
expect(localeCopy.every((locale) => isoRegex.test(locale.iso))).toBe(true)
})
it('should have matching language codes in code and iso properties', () => {
localeCopy.forEach((locale) => {
expect(locale.code).toBe(locale.iso.split('-')[0])
})
})
it('should have name property as a non-empty string', () => {
localeCopy.forEach((locale) => {
expect(typeof locale.name).toBe('string')
expect(locale.name.length).toBeGreaterThan(0)
})
})
it('should not have any additional unexpected properties', () => {
const expectedProps = ['name', 'code', 'iso', 'enabled']
localeCopy.forEach((locale) => {
const localeProps = Object.keys(locale)
expect(localeProps).toEqual(expect.arrayContaining(expectedProps))
expect(localeProps.length).toBe(expectedProps.length)
})
})
})

View File

@ -1,5 +1,5 @@
import Vue from 'vue'
import App from './App'
import { createApp } from 'vue'
import App from './App.vue'
// without this async calls are not working
import 'regenerator-runtime'
@ -11,36 +11,36 @@ import addNavigationGuards from './router/guards'
import i18n from './i18n'
import VueApollo from 'vue-apollo'
import PortalVue from 'portal-vue'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import { createBootstrap } from 'bootstrap-vue-next'
import { toasters } from './mixins/toaster'
// Add the necessary CSS
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue-next/dist/bootstrap-vue-next.css'
import { apolloProvider } from './plugins/apolloProvider'
Vue.use(PortalVue)
Vue.use(BootstrapVue)
export function createAdminApp() {
const app = createApp(App)
Vue.use(IconsPlugin)
app.use(router)
app.use(store)
Vue.use(VueApollo)
i18n.global.locale.value =
store.state.moderator && store.state.moderator.language ? store.state.moderator.language : 'en'
Vue.mixin(toasters)
app.use(i18n)
app.use(PortalVue)
app.use(createBootstrap())
addNavigationGuards(router, store, apolloProvider.defaultClient, i18n)
app.use(() => apolloProvider)
i18n.locale =
store.state.moderator && store.state.moderator.language ? store.state.moderator.language : 'en'
addNavigationGuards(router, store, apolloProvider.defaultClient, i18n)
return app
}
new Vue({
router,
store,
i18n,
apolloProvider,
render: (h) => h(App),
}).$mount('#app')
if (process.env.NODE_ENV !== 'test') {
const app = createAdminApp()
app.mount('#app')
}

View File

@ -1,110 +1,70 @@
import { ApolloClient, ApolloLink, InMemoryCache, HttpLink } from 'apollo-boost'
import './main'
import CONFIG from './config'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createApp } from 'vue'
import { createAdminApp } from '../src/main'
import Vue from 'vue'
import VueApollo from 'vue-apollo'
import i18n from './i18n'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import store from './store/store'
import router from './router/router'
// Mock dependencies
vi.mock('vue', () => ({
createApp: vi.fn(() => ({
use: vi.fn(),
mixin: vi.fn(),
mount: vi.fn(),
})),
}))
jest.mock('vue')
jest.mock('vue-apollo')
jest.mock('vuex')
jest.mock('vue-i18n')
jest.mock('./store/store', () => {
return {
state: {
moderator: {
language: 'es',
},
vi.mock('./App.vue', () => ({ default: {} }))
vi.mock('./store/store', () => ({
default: {
state: { moderator: { language: 'en' } },
},
}))
vi.mock('./router/router', () => ({ default: {} }))
vi.mock('./router/guards', () => ({ default: vi.fn() }))
vi.mock('./i18n', () => ({
default: {
global: {
locale: { value: 'en' },
},
}
})
jest.mock('./i18n')
jest.mock('./router/router')
},
}))
vi.mock('portal-vue', () => ({ default: {} }))
vi.mock('bootstrap-vue-next', () => ({ createBootstrap: vi.fn() }))
vi.mock('./mixins/toaster', () => ({ toasters: {} }))
vi.mock('./plugins/apolloProvider', () => ({ apolloProvider: { defaultClient: {} } }))
jest.mock('apollo-boost', () => {
return {
__esModule: true,
ApolloClient: jest.fn(),
ApolloLink: jest.fn(() => {
return { concat: jest.fn() }
}),
InMemoryCache: jest.fn(),
HttpLink: jest.fn(),
}
})
describe('main.js', () => {
let app
jest.mock('bootstrap-vue', () => {
return {
__esModule: true,
BootstrapVue: jest.fn(),
IconsPlugin: jest.fn(() => {
return { concat: jest.fn() }
}),
}
})
describe('main', () => {
it('calls the HttpLink', () => {
expect(HttpLink).toBeCalledWith({ uri: CONFIG.GRAPHQL_URI })
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
app = createAdminApp()
})
it('calls the ApolloLink', () => {
expect(ApolloLink).toBeCalled()
it('creates a Vue app', () => {
expect(createApp).toHaveBeenCalledWith(expect.anything())
})
it('calls the ApolloClient', () => {
expect(ApolloClient).toBeCalled()
it('uses the router plugin', () => {
expect(app.use).toHaveBeenCalled()
})
it('calls the InMemoryCache', () => {
expect(InMemoryCache).toBeCalled()
it('uses the Vuex store', () => {
expect(app.use).toHaveBeenCalled()
})
it('calls the VueApollo', () => {
expect(VueApollo).toBeCalled()
it('uses i18n plugin', () => {
expect(app.use).toHaveBeenCalled()
})
it('calls Vue', () => {
expect(Vue).toBeCalled()
it('uses PortalVue plugin', () => {
expect(app.use).toHaveBeenCalled()
})
it('calls i18n', () => {
expect(Vue).toBeCalledWith(
expect.objectContaining({
i18n,
}),
)
it('uses Bootstrap Vue plugin', () => {
expect(app.use).toHaveBeenCalled()
})
it('calls BootstrapVue', () => {
expect(Vue.use).toBeCalledWith(BootstrapVue)
})
it('calls IconsPlugin', () => {
expect(Vue.use).toBeCalledWith(IconsPlugin)
})
it('creates a store', () => {
expect(Vue).toBeCalledWith(
expect.objectContaining({
store,
}),
)
})
it('creates a router', () => {
expect(Vue).toBeCalledWith(
expect.objectContaining({
router,
}),
)
})
it('sets the locale from store', () => {
expect(i18n.locale).toBe('es')
it('uses Apollo provider', () => {
expect(app.use).toHaveBeenCalled()
})
})

View File

@ -1,62 +0,0 @@
import { adminOpenCreations } from '../graphql/adminOpenCreations'
export const creationMonths = {
data() {
return {
creation: [1000, 1000, 1000],
userId: 0,
}
},
computed: {
creationDates() {
const now = new Date(Date.now())
const dates = [now]
for (let i = 1; i < 3; i++) {
dates.push(new Date(now.getFullYear(), now.getMonth() - i, 1))
}
return dates.reverse()
},
creationDateObjects() {
const result = []
this.creationDates.forEach((date) => {
result.push({
short: this.$d(date, 'month'),
long: this.$d(date, 'short'),
year: this.$d(date, 'year'),
date: this.$d(date, 'short', 'en'),
})
})
return result
},
radioOptions() {
return this.creationDateObjects.map((obj, idx) => {
return {
item: { ...obj, creation: this.creation[idx] },
name: obj.short + (this.creation[idx] ? ' ' + this.creation[idx] + ' GDD' : ''),
}
})
},
creationLabel() {
return this.creationDates.map((date) => this.$d(date, 'monthShort')).join(' | ')
},
},
apollo: {
OpenCreations: {
query() {
return adminOpenCreations
},
variables() {
return {
userId: this.userId,
}
},
fetchPolicy: 'no-cache',
update({ adminOpenCreations }) {
this.creation = adminOpenCreations.map((obj) => obj.amount)
},
error({ message }) {
this.toastError(message)
},
},
},
}

View File

@ -1,30 +0,0 @@
export const toasters = {
methods: {
toastSuccess(message) {
this.toast(message, {
title: this.$t('success'),
variant: 'success',
})
},
toastError(message) {
this.toast(message, {
title: this.$t('error'),
variant: 'danger',
})
},
toast(message, options) {
// for unit tests, check that replace is present
if (message.replace) message = message.replace(/^GraphQL error: /, '')
this.$root.$bvToast.toast(message, {
autoHideDelay: 5000,
appendToast: true,
solid: true,
toaster: 'b-toaster-top-right',
headerClass: 'gdd-toaster-title',
bodyClass: 'gdd-toaster-body',
toastClass: 'gdd-toaster',
...options,
})
},
},
}

View File

@ -1,34 +0,0 @@
export const toggleRowDetails = {
data() {
return {
slotIndex: 0,
openRow: null,
creationUserData: {},
}
},
methods: {
rowToggleDetails(row, index) {
if (this.openRow) {
if (this.openRow.index === row.index) {
if (index === this.slotIndex) {
row.toggleDetails()
this.openRow = null
} else {
this.slotIndex = index
}
} else {
this.openRow.toggleDetails()
row.toggleDetails()
this.slotIndex = index
this.openRow = row
this.creationUserData = row.item
}
} else {
row.toggleDetails()
this.slotIndex = index
this.openRow = row
this.creationUserData = row.item
}
},
},
}

View File

@ -1,141 +0,0 @@
import { toggleRowDetails } from './toggleRowDetails'
import { mount } from '@vue/test-utils'
const localVue = global.localVue
const Component = {
render() {},
mixins: [toggleRowDetails],
}
const toggleDetailsMock = jest.fn()
const secondToggleDetailsMock = jest.fn()
const row = {
toggleDetails: toggleDetailsMock,
index: 0,
item: {
data: 'item-data',
},
}
let wrapper
describe('toggleRowDetails', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = mount(Component, { localVue })
})
it('sets default data', () => {
expect(wrapper.vm.slotIndex).toBe(0)
expect(wrapper.vm.openRow).toBe(null)
expect(wrapper.vm.creationUserData).toEqual({})
})
describe('no open row', () => {
beforeEach(() => {
wrapper.vm.rowToggleDetails(row, 2)
})
it('calls toggleDetails', () => {
expect(toggleDetailsMock).toBeCalled()
})
it('updates slot index', () => {
expect(wrapper.vm.slotIndex).toBe(2)
})
it('updates open row', () => {
expect(wrapper.vm.openRow).toEqual(
expect.objectContaining({
index: 0,
item: {
data: 'item-data',
},
}),
)
})
it('updates creation user data', () => {
expect(wrapper.vm.creationUserData).toEqual({ data: 'item-data' })
})
})
describe('with open row', () => {
beforeEach(() => {
wrapper.setData({ openRow: row })
})
describe('row index is open row index', () => {
describe('index is slot index', () => {
beforeEach(() => {
wrapper.vm.rowToggleDetails(row, 0)
})
it('calls toggleDetails', () => {
expect(toggleDetailsMock).toBeCalled()
})
it('sets open row to null', () => {
expect(wrapper.vm.openRow).toBe(null)
})
})
describe('index is not slot index', () => {
beforeEach(() => {
wrapper.vm.rowToggleDetails(row, 2)
})
it('does not call toggleDetails', () => {
expect(toggleDetailsMock).not.toBeCalled()
})
it('updates slot index', () => {
expect(wrapper.vm.slotIndex).toBe(2)
})
})
})
describe('row index is not open row index', () => {
beforeEach(() => {
wrapper.vm.rowToggleDetails(
{
toggleDetails: secondToggleDetailsMock,
index: 2,
item: {
data: 'new-item-data',
},
},
2,
)
})
it('closes the open row', () => {
expect(toggleDetailsMock).toBeCalled()
})
it('opens the new row', () => {
expect(secondToggleDetailsMock).toBeCalled()
})
it('updates slot index', () => {
expect(wrapper.vm.slotIndex).toBe(2)
})
it('updates open row', () => {
expect(wrapper.vm.openRow).toEqual({
toggleDetails: secondToggleDetailsMock,
index: 2,
item: {
data: 'new-item-data',
},
})
})
it('updates creation user data', () => {
expect(wrapper.vm.creationUserData).toEqual({ data: 'new-item-data' })
})
})
})
})

View File

@ -1,100 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import CommunityStatistic from './CommunityStatistic'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { toastErrorSpy } from '../../test/testSetup'
import VueApollo from 'vue-apollo'
import { createMockClient } from 'mock-apollo-client'
import { ref } from 'vue'
import CommunityStatistic from './CommunityStatistic.vue'
import StatisticTable from '../components/Tables/StatisticTable.vue'
import { useAppToast } from '@/composables/useToast'
import { useQuery } from '@vue/apollo-composable'
const mockClient = createMockClient()
const apolloProvider = new VueApollo({
defaultClient: mockClient,
})
vi.mock('@vue/apollo-composable', () => ({
useQuery: vi.fn(),
}))
const localVue = global.localVue
localVue.use(VueApollo)
vi.mock('@/composables/useToast', () => ({
useAppToast: vi.fn(),
}))
const defaultData = () => {
return {
communityStatistics: {
totalUsers: 3113,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
dynamicStatisticsFields: {
activeUsers: 1057,
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
const defaultData = {
communityStatistics: {
totalUsers: 3113,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
dynamicStatisticsFields: {
activeUsers: 1057,
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
}
}
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
},
}
describe('CommunityStatistic', () => {
let wrapper
let mockResult
let mockError
let mockLoading
let mockToastError
const communityStatisticsMock = jest.fn()
beforeEach(() => {
mockResult = ref(null)
mockError = ref(null)
mockLoading = ref(false)
mockToastError = vi.fn()
mockClient.setRequestHandler(
communityStatistics,
communityStatisticsMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: defaultData() }),
)
const Wrapper = () => {
return mount(CommunityStatistic, { localVue, mocks, apolloProvider })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
vi.mocked(useQuery).mockReturnValue({
result: mockResult,
loading: mockLoading,
error: mockError,
})
it('renders the Div Element ".community-statistic"', () => {
expect(wrapper.find('div.community-statistic').exists()).toBe(true)
vi.mocked(useAppToast).mockReturnValue({
toastError: mockToastError,
})
describe('server response for get statistics is an error', () => {
it('toast an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
wrapper = mount(CommunityStatistic, {
global: {
mocks: {
$t: (key) => key,
$n: (number) => number.toString(),
},
stubs: {
StatisticTable: true,
},
},
})
})
describe('server response for getting statistics is success', () => {
it('renders the data correctly', () => {
expect(wrapper.findAll('tr').at(1).findAll('td').at(1).text()).toEqual('3113')
expect(wrapper.findAll('tr').at(2).findAll('td').at(1).text()).toEqual('1057')
expect(wrapper.findAll('tr').at(3).findAll('td').at(1).text()).toEqual('35')
expect(wrapper.findAll('tr').at(4).findAll('td').at(1).text()).toEqual(
'4083774.05000000000000000000 GDD',
)
expect(wrapper.findAll('tr').at(4).findAll('td').at(2).text()).toEqual(
'4083774.05000000000000000000',
)
expect(wrapper.findAll('tr').at(5).findAll('td').at(1).text()).toEqual(
'-1062639.13634129622923372197 GDD',
)
expect(wrapper.findAll('tr').at(5).findAll('td').at(2).text()).toEqual(
'-1062639.13634129622923372197',
)
expect(wrapper.findAll('tr').at(6).findAll('td').at(1).text()).toEqual(
'2513565.869444365732411569 GDD',
)
expect(wrapper.findAll('tr').at(6).findAll('td').at(2).text()).toEqual(
'2513565.869444365732411569',
)
expect(wrapper.findAll('tr').at(7).findAll('td').at(1).text()).toEqual(
'-500474.6738366222166261272 GDD',
)
expect(wrapper.findAll('tr').at(7).findAll('td').at(2).text()).toEqual(
'-500474.6738366222166261272',
)
})
it('renders the component', () => {
expect(wrapper.find('.community-statistic').exists()).toBe(true)
})
it('renders StatisticTable when not loading', async () => {
mockLoading.value = false
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(StatisticTable).exists()).toBe(true)
})
it('does not render StatisticTable when loading', async () => {
mockLoading.value = true
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(StatisticTable).exists()).toBe(false)
})
it('calls toastError when there is an error', async () => {
mockError.value = new Error('Ouch!')
await wrapper.vm.$nextTick()
expect(mockToastError).toHaveBeenCalledWith('Ouch!')
})
it('updates statistics when result is available', async () => {
mockResult.value = defaultData
await wrapper.vm.$nextTick()
const statisticTable = wrapper.findComponent(StatisticTable)
expect(statisticTable.props('statistics')).toEqual({
totalUsers: 3113,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
activeUsers: 1057,
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
})
})
})

View File

@ -1,44 +1,43 @@
<template>
<div class="community-statistic">
<statistic-table v-model="statistics" />
<statistic-table v-if="!loading" :statistics="statistics" />
</div>
</template>
<script>
import { communityStatistics } from '@/graphql/communityStatistics.js'
import StatisticTable from '../components/Tables/StatisticTable'
export default {
name: 'CommunityStatistic',
components: {
StatisticTable,
<script setup>
import { ref, watch } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { communityStatistics } from '@/graphql/communityStatistics'
import StatisticTable from '../components/Tables/StatisticTable'
import { useAppToast } from '@/composables/useToast'
const statistics = ref({
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
})
const { result, loading, error } = useQuery(communityStatistics, () => ({}))
const { toastError } = useAppToast()
watch(
result,
() => {
if (!result.value) return
const totals = { ...result.value.communityStatistics.dynamicStatisticsFields }
statistics.value = { ...result.value.communityStatistics, ...totals }
delete statistics.value.dynamicStatisticsFields
},
data() {
return {
statistics: {
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
},
}
},
apollo: {
CommunityStatistics: {
query() {
return communityStatistics
},
update({ communityStatistics }) {
const totals = { ...communityStatistics.dynamicStatisticsFields }
this.statistics = { ...communityStatistics, ...totals }
delete this.statistics.dynamicStatisticsFields
},
error({ message }) {
this.toastError(message)
},
},
},
}
{ immediate: true },
)
watch(error, () => {
if (error.value) {
toastError(error.value.message)
}
})
</script>

View File

@ -1,12 +1,27 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ContributionLinks from './ContributionLinks'
import { ref } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import { toastErrorSpy } from '../../test/testSetup'
import { useAppToast } from '@/composables/useToast'
import ContributionLinks from '@/pages/ContributionLinks.vue'
const localVue = global.localVue
vi.mock('@vue/apollo-composable', () => ({
useQuery: vi.fn(),
}))
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
vi.mock('@/composables/useToast', () => ({
useAppToast: vi.fn(),
}))
describe('ContributionLink', () => {
let wrapper
let mockResult
let mockError
let mockRefetch
let mockToastError
const mockData = {
listContributionLinks: {
links: [
{
@ -24,55 +39,74 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
],
count: 1,
},
},
})
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$apollo: {
query: apolloQueryMock,
},
}
describe('ContributionLinks', () => {
// eslint-disable-next-line no-unused-vars
let wrapper
const Wrapper = () => {
return mount(ContributionLinks, { localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
beforeEach(() => {
mockResult = ref(null)
mockError = ref(null)
mockRefetch = vi.fn()
mockToastError = vi.fn()
vi.mocked(useQuery).mockReturnValue({
result: mockResult,
error: mockError,
refetch: mockRefetch,
})
describe('apollo returns', () => {
it('calls listContributionLinks', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: listContributionLinks,
}),
)
})
vi.mocked(useAppToast).mockReturnValue({
toastError: mockToastError,
})
describe('query transaction with error', () => {
beforeEach(() => {
apolloQueryMock.mockRejectedValue({ message: 'OUCH!' })
wrapper = Wrapper()
})
it('calls the API', () => {
expect(apolloQueryMock).toBeCalled()
})
it('toast error', () => {
expect(toastErrorSpy).toBeCalledWith(
'listContributionLinks has no result, use default data',
)
})
wrapper = mount(ContributionLinks, {
global: {
mocks: {
$t: (key) => key,
$d: (date) => date,
},
stubs: {
ContributionLink: true,
},
},
})
})
it('calls useQuery with listContributionLinks', () => {
expect(useQuery).toHaveBeenCalledWith(listContributionLinks, null, {
fetchPolicy: 'network-only',
})
})
it('renders the component', () => {
expect(wrapper.find('.contribution-link').exists()).toBe(true)
})
it('passes correct data to child component when query is successful', async () => {
mockResult.value = mockData
await wrapper.vm.$nextTick()
const childComponent = wrapper.findComponent({ name: 'ContributionLink' })
expect(childComponent.props('items')).toEqual(mockData.listContributionLinks.links)
expect(childComponent.props('count')).toBe(mockData.listContributionLinks.count)
})
it('passes empty data to child component when query result is null', async () => {
mockResult.value = null
await wrapper.vm.$nextTick()
const childComponent = wrapper.findComponent({ name: 'ContributionLink' })
expect(childComponent.props('items')).toEqual([])
expect(childComponent.props('count')).toBe(0)
})
it('calls toastError when there is an error', async () => {
mockError.value = new Error('OUCH!')
await wrapper.vm.$nextTick()
expect(mockToastError).toHaveBeenCalledWith(
'listContributionLinks has no result, use default data',
)
})
it('calls refetch when get-contribution-links event is emitted', async () => {
wrapper.findComponent({ name: 'ContributionLink' }).vm.$emit('get-contribution-links')
await wrapper.vm.$nextTick()
expect(mockRefetch).toHaveBeenCalled()
})
})

View File

@ -1,45 +1,31 @@
<template>
<div class="contribution-link">
<contribution-link
:items="items"
:count="count"
@get-contribution-links="getContributionLinks"
/>
<contribution-link :items="items" :count="count" @get-contribution-links="refetch" />
</div>
</template>
<script>
<script setup>
import { computed, watch } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import ContributionLink from '../components/ContributionLink/ContributionLink'
import { useAppToast } from '@/composables/useToast'
export default {
name: 'ContributionLinks',
components: {
ContributionLink,
},
data() {
return {
items: [],
count: 0,
}
},
methods: {
getContributionLinks() {
this.$apollo
.query({
query: listContributionLinks,
fetchPolicy: 'network-only',
})
.then((result) => {
this.count = result.data.listContributionLinks.count
this.items = result.data.listContributionLinks.links
})
.catch(() => {
this.toastError('listContributionLinks has no result, use default data')
})
},
},
created() {
this.getContributionLinks()
},
}
const { toastError } = useAppToast()
const { result, error, refetch } = useQuery(listContributionLinks, null, {
fetchPolicy: 'network-only',
})
const items = computed(() => {
return result.value?.listContributionLinks?.links || []
})
const count = computed(() => {
return result.value?.listContributionLinks?.count || 0
})
watch(error, () => {
toastError('listContributionLinks has no result, use default data')
})
</script>

View File

@ -1,554 +1,198 @@
import { mount } from '@vue/test-utils'
import CreationConfirm from './CreationConfirm'
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { denyContribution } from '../graphql/denyContribution'
import { adminListContributions } from '../graphql/adminListContributions'
import { confirmContribution } from '../graphql/confirmContribution'
import { getContribution } from '../graphql/getContribution'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
import VueApollo from 'vue-apollo'
import { createMockClient } from 'mock-apollo-client'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import CreationConfirm from './CreationConfirm.vue'
import { useQuery, useMutation } from '@vue/apollo-composable'
import { createStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import { useAppToast } from '@/composables/useToast'
import { BBadge, BPagination, BTab, BTabs } from 'bootstrap-vue-next'
const mockClient = createMockClient()
const apolloProvider = new VueApollo({
defaultClient: mockClient,
})
vi.mock('@vue/apollo-composable')
vi.mock('vue-i18n')
vi.mock('@/composables/useToast')
const localVue = global.localVue
localVue.use(VueApollo)
const storeCommitMock = jest.fn()
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$store: {
commit: storeCommitMock,
const createVuexStore = () => {
return createStore({
state: {
moderator: {
firstName: 'Peter',
lastName: 'Lustig',
roles: ['ADMIN'],
id: 263,
language: 'de',
openCreations: 0,
},
mutations: {
setOpenCreations(state, count) {
state.openCreations = count
},
openCreationsMinus(state, count) {
state.openCreations -= count
},
},
},
}
const defaultData = () => {
return {
adminListContributions: {
contributionCount: 30,
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(),
},
{
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: null,
contributionDate: new Date(),
deletedBy: null,
deletedAt: null,
updatedAt: null,
updatedBy: null,
createdAt: new Date(),
},
],
},
}
})
}
describe('CreationConfirm', () => {
let wrapper
const adminListContributionsMock = jest.fn()
const adminDeleteContributionMock = jest.fn()
const adminDenyContributionMock = jest.fn()
const confirmContributionMock = jest.fn()
const getContributionMock = jest.fn()
let store
let mockResult
let mockRefetch
let mockOnResultCallback
const mockToastError = vi.fn()
const mockToastSuccess = vi.fn()
const mockT = vi.fn((key) => key)
const mockD = vi.fn((date) => date.toISOString())
mockClient.setRequestHandler(
adminListContributions,
adminListContributionsMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: defaultData() }),
)
beforeEach(() => {
store = createVuexStore()
vi.spyOn(store, 'commit')
mockClient.setRequestHandler(
adminDeleteContribution,
adminDeleteContributionMock.mockResolvedValue({ data: { adminDeleteContribution: true } }),
)
mockResult = ref(null)
mockRefetch = vi.fn()
mockOnResultCallback = null
mockClient.setRequestHandler(
denyContribution,
adminDenyContributionMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: { denyContribution: true } }),
)
mockClient.setRequestHandler(
confirmContribution,
confirmContributionMock.mockResolvedValue({ data: { confirmContribution: true } }),
)
mockClient.setRequestHandler(getContribution, getContributionMock.mockResolvedValue({ data: {} }))
const Wrapper = () => {
return mount(CreationConfirm, { localVue, mocks, apolloProvider })
}
describe('mount', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = Wrapper()
useQuery.mockReturnValue({
onResult: (callback) => {
mockOnResultCallback = callback
},
onError: vi.fn(),
result: mockResult,
refetch: mockRefetch,
})
describe('server response for get pending creations is error', () => {
it('toast an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
it('has statusFilter ["IN_PROGRESS", "PENDING"]', () => {
expect(wrapper.vm.statusFilter).toEqual(['IN_PROGRESS', 'PENDING'])
})
useMutation.mockReturnValue({
mutate: vi.fn(),
onDone: vi.fn(),
onError: vi.fn(),
})
describe('server response is success', () => {
it('has a DIV element with the class.creation-confirm', () => {
expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy()
})
it('has two pending creations', () => {
expect(wrapper.find('tbody').findAll('tr')).toHaveLength(2)
})
useI18n.mockReturnValue({
t: mockT,
d: mockD,
})
describe('actions in overlay', () => {
describe('delete creation', () => {
beforeEach(async () => {
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
it('opens the overlay', () => {
expect(wrapper.find('#overlay').isVisible()).toBeTruthy()
})
describe('with success', () => {
describe('cancel deletion', () => {
beforeEach(async () => {
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
})
it('closes the overlay', async () => {
expect(wrapper.find('#overlay').exists()).toBeFalsy()
})
it('still has 2 items in the table', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(2)
})
})
describe('confirm deletion', () => {
beforeEach(async () => {
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
it('calls the adminDeleteContribution mutation', () => {
expect(adminDeleteContributionMock).toBeCalledWith({ id: 1 })
})
it('commits openCreationsMinus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
})
it('has 1 item left in the table', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(1)
})
})
describe('with error', () => {
beforeEach(async () => {
adminDeleteContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
})
})
})
})
describe('confirm creation', () => {
beforeEach(async () => {
await wrapper.findAll('tr').at(2).findAll('button').at(3).trigger('click')
})
it('opens the overlay', () => {
expect(wrapper.find('#overlay').isVisible()).toBeTruthy()
})
describe('with success', () => {
describe('cancel confirmation', () => {
beforeEach(async () => {
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
})
it('closes the overlay', async () => {
expect(wrapper.find('#overlay').exists()).toBeFalsy()
})
it('still has 2 items in the table', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(2)
})
})
describe('confirm confirmation', () => {
beforeEach(async () => {
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
it('calls the confirmContribution mutation', () => {
expect(confirmContributionMock).toBeCalledWith({ id: 2 })
})
it('commits openCreationsMinus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_created')
})
it('has 1 item left in the table', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(1)
})
})
})
describe('with error', () => {
beforeEach(async () => {
confirmContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
})
})
})
describe('deny creation', () => {
beforeEach(async () => {
await wrapper.findAll('tr').at(1).findAll('button').at(1).trigger('click')
})
it('opens the overlay', () => {
expect(wrapper.find('#overlay').isVisible()).toBeTruthy()
})
describe('with success', () => {
describe('cancel deny', () => {
beforeEach(async () => {
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
})
it('closes the overlay', async () => {
expect(wrapper.find('#overlay').exists()).toBeFalsy()
})
it('still has 2 items in the table', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(2)
})
})
describe('confirm deny', () => {
beforeEach(async () => {
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
it('calls the denyContribution mutation', () => {
expect(adminDenyContributionMock).toBeCalledWith({ id: 1 })
})
it('commits openCreationsMinus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_denied')
})
it('has 1 item left in the table', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(1)
})
})
})
describe('with error', () => {
beforeEach(async () => {
adminDenyContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
})
})
})
useAppToast.mockReturnValue({
toastError: mockToastError,
toastSuccess: mockToastSuccess,
})
describe('filter tabs', () => {
describe('click tab "confirmed"', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.find('a[data-test="confirmed"]').trigger('click')
})
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: '',
statusFilter: ['CONFIRMED'],
})
})
describe('click tab "open"', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.find('a[data-test="open"]').trigger('click')
})
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: '',
statusFilter: ['IN_PROGRESS', 'PENDING'],
})
})
})
describe('click tab "denied"', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.find('a[data-test="denied"]').trigger('click')
})
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: '',
statusFilter: ['DENIED'],
})
})
})
describe('click tab "deleted"', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.find('a[data-test="deleted"]').trigger('click')
})
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: '',
statusFilter: ['DELETED'],
})
})
})
describe('click tab "all"', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.find('a[data-test="all"]').trigger('click')
})
it('refetches contributions with proper filter', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: '',
statusFilter: ['IN_PROGRESS', 'PENDING', 'CONFIRMED', 'DENIED', 'DELETED'],
})
})
describe('change pagination', () => {
it('has pagination buttons', () => {
expect(wrapper.findComponent({ name: 'BPagination' }).exists()).toBe(true)
})
describe('next page', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper.findComponent({ name: 'BPagination' }).vm.$emit('input', 2)
})
it('calls the API again', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 2,
hideResubmission: false,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: '',
statusFilter: ['IN_PROGRESS', 'PENDING', 'CONFIRMED', 'DENIED', 'DELETED'],
})
})
describe('click tab "open" again', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.find('a[data-test="open"]').trigger('click')
})
it('refetches contributions with proper filter and current page = 1', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: '',
statusFilter: ['IN_PROGRESS', 'PENDING'],
})
})
})
})
})
})
})
})
describe('user query', () => {
describe('with user query', () => {
beforeEach(() => {
wrapper.findComponent({ name: 'UserQuery' }).vm.$emit('input', 'query')
})
it('calls the API with query', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: 'query',
statusFilter: ['IN_PROGRESS', 'PENDING'],
})
})
describe('reset query', () => {
beforeEach(() => {
wrapper.findComponent({ name: 'UserQuery' }).vm.$emit('input', '')
})
it('calls the API with empty query', () => {
expect(adminListContributionsMock).toBeCalledWith({
currentPage: 1,
hideResubmission: true,
noHashtag: null,
order: 'DESC',
pageSize: 25,
query: '',
statusFilter: ['IN_PROGRESS', 'PENDING'],
})
})
})
})
})
describe('update status', () => {
beforeEach(async () => {
await wrapper.findComponent({ name: 'OpenCreationsTable' }).vm.$emit('update-status', 2)
})
it('updates the status', () => {
expect(wrapper.vm.items.find((obj) => obj.id === 2).messagesCount).toBe(1)
expect(wrapper.vm.items.find((obj) => obj.id === 2).status).toBe('IN_PROGRESS')
})
})
describe('reload contribution', () => {
beforeEach(async () => {
await wrapper
.findComponent({ name: 'OpenCreationsTable' })
.vm.$emit('reload-contribution', 1)
})
it('reloaded contribution', () => {
expect(getContributionMock).toBeCalledWith({
id: 1,
})
})
})
describe('unknown variant', () => {
beforeEach(async () => {
await wrapper.setData({ variant: 'unknown' })
})
it('has overlay icon "info"', () => {
expect(wrapper.vm.overlayIcon).toBe('info')
})
wrapper = mount(CreationConfirm, {
global: {
plugins: [store],
stubs: {
UserQuery: true,
BButton: true,
BTabs,
BTab,
BBadge,
OpenCreationsTable: true,
BPagination,
Overlay: true,
IBiBellFill: true,
IBiCheck: true,
IBiXCircle: true,
IBiTrash: true,
IBiList: true,
},
mocks: {
$t: mockT,
$d: mockD,
},
},
})
})
const simulateQueryResult = async (data) => {
mockResult.value = data
if (mockOnResultCallback) {
mockOnResultCallback({ data })
}
await nextTick()
}
it('initializes with correct default values', () => {
expect(wrapper.vm.tabIndex).toBe(0)
expect(wrapper.vm.currentPage).toBe(1)
expect(wrapper.vm.pageSize).toBe(25)
expect(wrapper.vm.query).toBe('')
expect(wrapper.vm.noHashtag).toBe(null)
expect(wrapper.vm.hideResubmissionModel).toBe(true)
})
it('updates store and component state when open creations are fetched', async () => {
const mockData = {
adminListContributions: {
contributionCount: 5,
contributionList: Array(5)
.fill({})
.map((_, i) => ({ id: i + 1 })),
},
}
await simulateQueryResult(mockData)
expect(store.commit).toHaveBeenCalledWith('setOpenCreations', 5)
expect(wrapper.vm.rows).toBe(5)
expect(wrapper.vm.items).toEqual(mockData.adminListContributions.contributionList)
})
it('does not update store when not on the open tab', async () => {
wrapper.vm.tabIndex = 1
await nextTick()
const mockData = {
adminListContributions: {
contributionCount: 10,
contributionList: Array(10)
.fill({})
.map((_, i) => ({ id: i + 1 })),
},
}
await simulateQueryResult(mockData)
expect(store.commit).not.toHaveBeenCalledWith('setOpenCreations', 10)
expect(wrapper.vm.rows).toBe(10)
expect(wrapper.vm.items).toEqual(mockData.adminListContributions.contributionList)
})
it('refetches data when filters change', async () => {
wrapper.vm.query = 'test query'
await nextTick()
expect(mockRefetch).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test query',
}),
)
wrapper.vm.noHashtag = true
await nextTick()
expect(mockRefetch).toHaveBeenCalledWith(
expect.objectContaining({
noHashtag: true,
}),
)
})
it('updates tabIndex and refetches when changing tabs', async () => {
wrapper.vm.tabIndex = 2
await nextTick()
expect(wrapper.vm.currentPage).toBe(1)
expect(mockRefetch).toHaveBeenCalledWith(
expect.objectContaining({
currentPage: 1,
statusFilter: ['DENIED'],
}),
)
})
it('handles pagination changes', async () => {
wrapper.vm.currentPage = 2
await nextTick()
expect(mockRefetch).toHaveBeenCalledWith(
expect.objectContaining({
currentPage: 2,
}),
)
})
})

View File

@ -1,77 +1,77 @@
<!-- eslint-disable @intlify/vue-i18n/no-dynamic-keys -->
<template>
<div class="creation-confirm">
<user-query class="mb-2 mt-2" v-model="query" :placeholder="$t('user_memo_search')" />
<user-query v-model="query" class="mb-2 mt-2" :placeholder="$t('user_memo_search')" />
<p class="mb-2">
<input type="checkbox" class="noHashtag" v-model="noHashtag" />
<span class="ml-2" v-b-tooltip="$t('no_hashtag_tooltip')">{{ $t('no_hashtag') }}</span>
<input v-model="noHashtag" type="checkbox" class="noHashtag" />
<span v-b-tooltip="$t('no_hashtag_tooltip')" class="ms-2">{{ $t('no_hashtag') }}</span>
</p>
<p class="mb-4" v-if="showResubmissionCheckbox">
<input type="checkbox" class="hideResubmission" v-model="hideResubmissionModel" />
<span class="ml-2" v-b-tooltip="$t('hide_resubmission_tooltip')">
<p v-if="showResubmissionCheckbox" class="mb-4">
<input v-model="hideResubmissionModel" type="checkbox" class="hideResubmission" />
<span v-b-tooltip="$t('hide_resubmission_tooltip')" class="ms-2">
{{ $t('hide_resubmission') }}
</span>
</p>
<div>
<b-tabs v-model="tabIndex" content-class="mt-3" fill>
<b-tab active :title-link-attributes="{ 'data-test': 'open' }">
<BTabs v-model="tabIndex" content-class="mt-3" fill>
<BTab active :title-link-attributes="{ 'data-test': 'open' }">
<template #title>
<b-icon icon="bell-fill" variant="primary"></b-icon>
<IBiBellFill style="color: #0d6efd" />
{{ $t('contributions.open') }}
<b-badge v-if="$store.state.openCreations > 0" variant="danger">
<BBadge v-if="$store.state.openCreations > 0" variant="danger">
{{ $store.state.openCreations }}
</b-badge>
</BBadge>
</template>
</b-tab>
<b-tab :title-link-attributes="{ 'data-test': 'confirmed' }">
</BTab>
<BTab :title-link-attributes="{ 'data-test': 'confirmed' }">
<template #title>
<b-icon icon="check" variant="success"></b-icon>
<IBiCheck style="color: #198754" />
{{ $t('contributions.confirms') }}
</template>
</b-tab>
<b-tab :title-link-attributes="{ 'data-test': 'denied' }">
</BTab>
<BTab :title-link-attributes="{ 'data-test': 'denied' }">
<template #title>
<b-icon icon="x-circle" variant="warning"></b-icon>
<IBiXCircle style="color: #ffc107" />
{{ $t('contributions.denied') }}
</template>
</b-tab>
<b-tab :title-link-attributes="{ 'data-test': 'deleted' }">
</BTab>
<BTab :title-link-attributes="{ 'data-test': 'deleted' }">
<template #title>
<b-icon icon="trash" variant="danger"></b-icon>
<IBiTrash style="color: #dc3545" />
{{ $t('contributions.deleted') }}
</template>
</b-tab>
<b-tab :title-link-attributes="{ 'data-test': 'all' }">
</BTab>
<BTab :title-link-attributes="{ 'data-test': 'all' }">
<template #title>
<b-icon icon="list"></b-icon>
<IBiList />
{{ $t('contributions.all') }}
</template>
</b-tab>
</b-tabs>
</BTab>
</BTabs>
</div>
<open-creations-table
class="mt-4"
:items="items"
:fields="fields"
:hideResubmission="hideResubmission"
:hide-resubmission="hideResubmission"
@show-overlay="showOverlay"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@update-contributions="$apollo.queries.ListAllContributions.refetch()"
@update-contributions="refetch"
/>
<b-pagination
<BPagination
v-model="currentPage"
pills
size="lg"
v-model="currentPage"
:per-page="pageSize"
:total-rows="rows"
align="center"
:hide-ellipsis="true"
></b-pagination>
/>
<div v-if="overlay" id="overlay" @dblclick="overlay = false">
<overlay :item="item" @overlay-cancel="overlay = false">
<Overlay :item="item" @overlay-cancel="overlay = false">
<template #title>
{{ $t(overlayTitle) }}
</template>
@ -82,20 +82,21 @@
<p>{{ $t(overlayQuestion) }}</p>
</template>
<template #submit-btn>
<b-button
size="md"
v-bind:variant="overlayIcon"
class="m-3 text-right"
@click="overlayEvent"
>
<BButton size="md" :variant="overlayIcon" class="m-3 text-end" @click="overlayEvent">
{{ $t(overlayBtnText) }}
</b-button>
</BButton>
</template>
</overlay>
</Overlay>
</div>
</div>
</template>
<script>
<script setup>
import { ref, computed, watch } from 'vue'
import { useQuery, useMutation } from '@vue/apollo-composable'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import Overlay from '../components/Overlay'
import OpenCreationsTable from '../components/Tables/OpenCreationsTable'
import UserQuery from '../components/UserQuery'
@ -104,6 +105,7 @@ import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { confirmContribution } from '../graphql/confirmContribution'
import { denyContribution } from '../graphql/denyContribution'
import { getContribution } from '../graphql/getContribution'
import { useAppToast } from '@/composables/useToast'
const FILTER_TAB_MAP = [
['IN_PROGRESS', 'PENDING'],
@ -113,363 +115,346 @@ const FILTER_TAB_MAP = [
['IN_PROGRESS', 'PENDING', 'CONFIRMED', 'DENIED', 'DELETED'],
]
export default {
name: 'CreationConfirm',
components: {
OpenCreationsTable,
Overlay,
UserQuery,
const store = useStore()
const { t, d } = useI18n()
const { toastError, toastSuccess } = useAppToast()
const tabIndex = ref(0)
const items = ref([])
const overlay = ref(false)
const item = ref({})
const variant = ref('confirm')
const rows = ref(0)
const currentPage = ref(1)
const pageSize = ref(25)
const query = ref('')
const noHashtag = ref(null)
const hideResubmissionModel = ref(true)
const fields = computed(
() =>
[
// open contributions
[
{ key: 'bookmark', label: t('delete') },
{ key: 'deny', label: t('deny') },
{ key: 'firstName', label: t('firstname') },
{ key: 'lastName', label: t('lastname') },
{
key: 'amount',
label: t('creation'),
formatter: (value) => value + ' GDD',
},
{ key: 'memo', label: t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: t('created'),
formatter: (value) => formatDateOrDash(value),
},
{ key: 'moderatorId', label: t('moderator.moderator') },
{ key: 'editCreation', label: t('chat') },
{ key: 'confirm', label: t('save') },
],
// confirmed contributions
[
{ key: 'firstName', label: t('firstname') },
{ key: 'lastName', label: t('lastname') },
{
key: 'amount',
label: t('creation'),
formatter: (value) => value + ' GDD',
},
{ key: 'memo', label: t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: t('created'),
formatter: (value) => formatDateOrDash(value),
},
{
key: 'createdAt',
label: t('createdAt'),
formatter: (value) => formatDateOrDash(value),
},
{
key: 'confirmedAt',
label: t('contributions.confirms'),
formatter: (value) => formatDateOrDash(value),
},
{ key: 'confirmedBy', label: t('moderator.moderator') },
{ key: 'chatCreation', label: t('chat') },
],
// denied contributions
[
{ key: 'firstName', label: t('firstname') },
{ key: 'lastName', label: t('lastname') },
{
key: 'amount',
label: t('creation'),
formatter: (value) => value + ' GDD',
},
{ key: 'memo', label: t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: t('created'),
formatter: (value) => formatDateOrDash(value),
},
{
key: 'createdAt',
label: t('createdAt'),
formatter: (value) => formatDateOrDash(value),
},
{
key: 'deniedAt',
label: t('contributions.denied'),
formatter: (value) => formatDateOrDash(value),
},
{ key: 'deniedBy', label: t('moderator.moderator') },
{ key: 'chatCreation', label: t('chat') },
],
// deleted contributions
[
{ key: 'firstName', label: t('firstname') },
{ key: 'lastName', label: t('lastname') },
{
key: 'amount',
label: t('creation'),
formatter: (value) => value + ' GDD',
},
{ key: 'memo', label: t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: t('created'),
formatter: (value) => formatDateOrDash(value),
},
{
key: 'createdAt',
label: t('createdAt'),
formatter: (value) => formatDateOrDash(value),
},
{
key: 'deletedAt',
label: t('contributions.deleted'),
formatter: (value) => formatDateOrDash(value),
},
{ key: 'deletedBy', label: t('moderator.moderator') },
{ key: 'chatCreation', label: t('chat') },
],
// all contributions
[
{ key: 'status', label: t('status') },
{ key: 'firstName', label: t('firstname') },
{ key: 'lastName', label: t('lastname') },
{
key: 'amount',
label: t('creation'),
formatter: (value) => value + ' GDD',
},
{ key: 'memo', label: t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: t('created'),
formatter: (value) => formatDateOrDash(value),
},
{
key: 'createdAt',
label: t('createdAt'),
formatter: (value) => formatDateOrDash(value),
},
{
key: 'confirmedAt',
label: t('contributions.confirms'),
formatter: (value) => formatDateOrDash(value),
},
{ key: 'confirmedBy', label: t('moderator.moderator') },
{ key: 'chatCreation', label: t('chat') },
],
][tabIndex.value],
)
const statusFilter = computed(() => [...FILTER_TAB_MAP[tabIndex.value]])
const overlayTitle = computed(() => `overlay.${variant.value}.title`)
const overlayText = computed(() => `overlay.${variant.value}.text`)
const overlayQuestion = computed(() => `overlay.${variant.value}.question`)
const overlayBtnText = computed(() => `overlay.${variant.value}.yes`)
const overlayEvent = computed(() => {
switch (variant.value) {
case 'confirm':
return confirmCreation
case 'deny':
return denyCreation
case 'delete':
return deleteCreation
default:
return null
}
})
const overlayIcon = computed(() => {
switch (variant.value) {
case 'confirm':
return 'success'
case 'deny':
return 'warning'
case 'delete':
return 'danger'
default:
return 'info'
}
})
const showResubmissionCheckbox = computed(() => tabIndex.value === 0)
const hideResubmission = computed(() =>
showResubmissionCheckbox.value ? hideResubmissionModel.value : false,
)
watch(tabIndex, () => {
currentPage.value = 1
items.value = []
})
const { onResult, onError, result, refetch } = useQuery(
adminListContributions,
{
currentPage: currentPage.value,
pageSize: pageSize.value,
statusFilter: statusFilter.value,
query: query.value,
noHashtag: noHashtag.value,
hideResubmission: hideResubmission.value,
},
data() {
return {
tabIndex: 0,
items: [],
overlay: false,
item: {},
variant: 'confirm',
rows: 0,
currentPage: 1,
pageSize: 25,
query: '',
noHashtag: null,
hideResubmissionModel: true,
}
},
watch: {
tabIndex() {
this.currentPage = 1
},
},
methods: {
reloadContribution(id) {
this.$apollo
.query({ query: getContribution, variables: { id } })
.then((result) => {
const contribution = result.data.contribution
this.$set(
this.items,
this.items.findIndex((obj) => obj.id === contribution.id),
contribution,
)
})
.catch((error) => {
this.overlay = false
this.toastError(error.message)
})
},
deleteCreation() {
this.$apollo
.mutate({
mutation: adminDeleteContribution,
variables: {
id: this.item.id,
},
})
.then((result) => {
this.overlay = false
this.updatePendingCreations(this.item.id)
this.toastSuccess(this.$t('creation_form.toasted_delete'))
})
.catch((error) => {
this.overlay = false
this.toastError(error.message)
})
},
denyCreation() {
this.$apollo
.mutate({
mutation: denyContribution,
variables: {
id: this.item.id,
},
})
.then((result) => {
this.overlay = false
this.updatePendingCreations(this.item.id)
this.toastSuccess(this.$t('creation_form.toasted_denied'))
})
.catch((error) => {
this.overlay = false
this.toastError(error.message)
})
},
confirmCreation() {
this.$apollo
.mutate({
mutation: confirmContribution,
variables: {
id: this.item.id,
},
})
.then((result) => {
this.overlay = false
this.updatePendingCreations(this.item.id)
this.toastSuccess(this.$t('creation_form.toasted_created'))
})
.catch((error) => {
this.overlay = false
this.toastError(error.message)
})
},
updatePendingCreations(id) {
this.items = this.items.filter((obj) => obj.id !== id)
this.$store.commit('openCreationsMinus', 1)
},
showOverlay(item, variant) {
this.overlay = true
this.item = item
this.variant = variant
},
updateStatus(id) {
this.items.find((obj) => obj.id === id).messagesCount++
this.items.find((obj) => obj.id === id).status = 'IN_PROGRESS'
},
formatDateOrDash(value) {
return value ? this.$d(new Date(value), 'short') : '—'
},
},
computed: {
fields() {
return [
[
// open contributions
{ key: 'bookmark', label: this.$t('delete') },
{ key: 'deny', label: this.$t('deny') },
{ key: 'firstName', label: this.$t('firstname') },
{ key: 'lastName', label: this.$t('lastname') },
{
key: 'amount',
label: this.$t('creation'),
formatter: (value) => {
return value + ' GDD'
},
},
{ key: 'memo', label: this.$t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: this.$t('created'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{ key: 'moderatorId', label: this.$t('moderator.moderator') },
{ key: 'editCreation', label: this.$t('chat') },
{ key: 'confirm', label: this.$t('save') },
],
[
// confirmed contributions
{ key: 'firstName', label: this.$t('firstname') },
{ key: 'lastName', label: this.$t('lastname') },
{
key: 'amount',
label: this.$t('creation'),
formatter: (value) => {
return value + ' GDD'
},
},
{ key: 'memo', label: this.$t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: this.$t('created'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{
key: 'createdAt',
label: this.$t('createdAt'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{
key: 'confirmedAt',
label: this.$t('contributions.confirms'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{ key: 'confirmedBy', label: this.$t('moderator.moderator') },
{ key: 'chatCreation', label: this.$t('chat') },
],
[
// denied contributions
{ key: 'firstName', label: this.$t('firstname') },
{ key: 'lastName', label: this.$t('lastname') },
{
key: 'amount',
label: this.$t('creation'),
formatter: (value) => {
return value + ' GDD'
},
},
{ key: 'memo', label: this.$t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: this.$t('created'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{
key: 'createdAt',
label: this.$t('createdAt'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{
key: 'deniedAt',
label: this.$t('contributions.denied'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{ key: 'deniedBy', label: this.$t('moderator.moderator') },
{ key: 'chatCreation', label: this.$t('chat') },
],
[
// deleted contributions
{ key: 'firstName', label: this.$t('firstname') },
{ key: 'lastName', label: this.$t('lastname') },
{
key: 'amount',
label: this.$t('creation'),
formatter: (value) => {
return value + ' GDD'
},
},
{ key: 'memo', label: this.$t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: this.$t('created'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{
key: 'createdAt',
label: this.$t('createdAt'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{
key: 'deletedAt',
label: this.$t('contributions.deleted'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{ key: 'deletedBy', label: this.$t('moderator.moderator') },
{ key: 'chatCreation', label: this.$t('chat') },
],
[
// all contributions
{ key: 'status', label: this.$t('status') },
{ key: 'firstName', label: this.$t('firstname') },
{ key: 'lastName', label: this.$t('lastname') },
{
key: 'amount',
label: this.$t('creation'),
formatter: (value) => {
return value + ' GDD'
},
},
{ key: 'memo', label: this.$t('text'), class: 'text-break' },
{
key: 'contributionDate',
label: this.$t('created'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{
key: 'createdAt',
label: this.$t('createdAt'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{
key: 'confirmedAt',
label: this.$t('contributions.confirms'),
formatter: (value) => {
return this.formatDateOrDash(value)
},
},
{ key: 'confirmedBy', label: this.$t('moderator.moderator') },
{ key: 'chatCreation', label: this.$t('chat') },
],
][this.tabIndex]
},
statusFilter() {
return FILTER_TAB_MAP[this.tabIndex]
},
overlayTitle() {
return `overlay.${this.variant}.title`
},
overlayText() {
return `overlay.${this.variant}.text`
},
overlayQuestion() {
return `overlay.${this.variant}.question`
},
overlayBtnText() {
return `overlay.${this.variant}.yes`
},
overlayEvent() {
return this[`${this.variant}Creation`]
},
overlayIcon() {
switch (this.variant) {
case 'confirm':
return 'success'
case 'deny':
return 'warning'
case 'delete':
return 'danger'
default:
return 'info'
}
},
showResubmissionCheckbox() {
return this.tabIndex === 0
},
hideResubmission() {
return this.showResubmissionCheckbox ? this.hideResubmissionModel : false
},
},
apollo: {
ListAllContributions: {
query() {
return adminListContributions
},
variables() {
return {
currentPage: this.currentPage,
pageSize: this.pageSize,
statusFilter: this.statusFilter,
query: this.query,
noHashtag: this.noHashtag,
hideResubmission: this.hideResubmission,
}
},
fetchPolicy: 'no-cache',
update({ adminListContributions }) {
this.rows = adminListContributions.contributionCount
this.items = adminListContributions.contributionList
if (this.statusFilter === FILTER_TAB_MAP[0]) {
this.$store.commit('setOpenCreations', adminListContributions.contributionCount)
}
},
error({ message }) {
this.toastError(message)
},
},
{
fetchPolicy: 'no-cache',
},
)
watch([statusFilter, query, noHashtag, hideResubmission, currentPage], () => {
refetch({
currentPage: currentPage.value,
pageSize: pageSize.value,
statusFilter: statusFilter.value,
query: query.value,
noHashtag: noHashtag.value,
hideResubmission: hideResubmission.value,
})
})
onError((error) => {
toastError(error.message)
})
onResult(() => {
rows.value = result.value.adminListContributions.contributionCount
items.value = result.value.adminListContributions.contributionList
if (statusFilter.value.toString() === FILTER_TAB_MAP[0].toString()) {
store.commit('setOpenCreations', result.value.adminListContributions.contributionCount)
}
})
const {
mutate: deleteMutation,
onDone: onDeleteDone,
onError: onDeleteError,
} = useMutation(adminDeleteContribution)
onDeleteDone(() => {
overlay.value = false
updatePendingCreations(item.value.id)
toastSuccess(t('creation_form.toasted_delete'))
})
onDeleteError((error) => {
overlay.value = false
toastError(error.message)
})
const {
mutate: denyMutation,
onDone: onDenayDone,
onError: onDenayError,
} = useMutation(denyContribution)
onDenayDone(() => {
overlay.value = false
updatePendingCreations(item.value.id)
toastSuccess(t('creation_form.toasted_denied'))
})
onDenayError((error) => {
overlay.value = false
toastError(error.message)
})
const {
mutate: confirmMutation,
onDone: onConfirmationDone,
onError: onConfirmationError,
} = useMutation(confirmContribution)
onConfirmationDone(() => {
overlay.value = false
updatePendingCreations(item.value.id)
toastSuccess(t('creation_form.toasted_created'))
})
onConfirmationError((error) => {
overlay.value = false
toastError(error.message)
})
const reloadContribution = (id) => {
useQuery(getContribution, { id })
.onResult((result) => {
const contribution = result.data.contribution
const index = items.value.findIndex((obj) => obj.id === contribution.id)
items.value[index] = contribution
})
.onError((error) => {
overlay.value = false
toastError(error.message)
})
}
const deleteCreation = () => {
deleteMutation({
id: item.value.id,
})
}
const denyCreation = () => {
denyMutation({
id: item.value.id,
})
}
const confirmCreation = () => {
confirmMutation({
id: item.value.id,
})
}
const updatePendingCreations = (id) => {
items.value = items.value.filter((obj) => obj.id !== id)
store.commit('openCreationsMinus', 1)
}
const showOverlay = (selectedItem, selectedVariant) => {
overlay.value = true
item.value = selectedItem
variant.value = selectedVariant
}
const updateStatus = (id) => {
const target = items.value.find((obj) => obj.id === id)
if (target) {
target.messagesCount++
target.status = 'IN_PROGRESS'
}
}
const formatDateOrDash = (value) => {
return value ? d(new Date(value), 'short') : '—'
}
</script>
<style>
#overlay {
position: fixed;
@ -477,12 +462,9 @@ export default {
align-items: center;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
padding-left: 5%;
background-color: rgba(12, 11, 11, 0.781);
background-color: rgb(12 11 11 / 78.1%);
z-index: 1000000;
cursor: pointer;
}

View File

@ -1,137 +1,110 @@
import { mount } from '@vue/test-utils'
import FederationVisualize from './FederationVisualize'
import VueApollo from 'vue-apollo'
import { createMockClient } from 'mock-apollo-client'
import { allCommunities } from '@/graphql/allCommunities'
import { toastErrorSpy } from '../../test/testSetup'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import FederationVisualize from './FederationVisualize.vue'
import { useQuery } from '@vue/apollo-composable'
import { useAppToast } from '@/composables/useToast'
import { BButton, BListGroup, BRow, BCol, BListGroupItem } from 'bootstrap-vue-next'
const mockClient = createMockClient()
const apolloProvider = new VueApollo({
defaultClient: mockClient,
})
const localVue = global.localVue
localVue.use(VueApollo)
const mocks = {
$t: (key) => key,
$d: jest.fn((d) => d),
$i18n: {
locale: 'en',
t: (key) => key,
},
}
const defaultData = () => {
return {
allCommunities: [
{
id: 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: '2024-01-09T15:56:40.592Z',
createdAt: '2024-01-09T15:56:40.595Z',
updatedAt: '2024-01-16T11:17:15.000Z',
federatedCommunities: [
{
id: 2046,
apiVersion: '2_0',
endPoint: 'http://localhost/api/',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: '2024-01-16T10:08:21.544Z',
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,
__typename: 'FederatedCommunity',
},
{
id: 2044,
apiVersion: '1_0',
endPoint: 'http://localhost/api/',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: '2024-01-16T10:08:21.544Z',
updatedAt: null,
__typename: 'FederatedCommunity',
},
],
},
],
}
}
vi.mock('@vue/apollo-composable')
vi.mock('@/composables/useToast')
describe('FederationVisualize', () => {
let wrapper
const allCommunitiesMock = jest.fn()
let mockResult
let mockLoading
let mockRefetch
let mockError
const mockToastError = vi.fn()
mockClient.setRequestHandler(
allCommunities,
allCommunitiesMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: defaultData() }),
)
beforeEach(() => {
mockResult = ref(null)
mockLoading = ref(false)
mockRefetch = vi.fn()
mockError = ref(null)
const Wrapper = () => {
return mount(FederationVisualize, { localVue, mocks, apolloProvider })
}
describe('mount', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = Wrapper()
useQuery.mockReturnValue({
result: mockResult,
loading: mockLoading,
refetch: mockRefetch,
error: mockError,
})
describe('server error', () => {
it('toast error', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
useAppToast.mockReturnValue({
toastError: mockToastError,
})
describe('sever success', () => {
it('sends query to Apollo when created', () => {
expect(allCommunitiesMock).toBeCalled()
})
it('has a DIV element with the class "federation-visualize"', () => {
expect(wrapper.find('div.federation-visualize').exists()).toBe(true)
})
it('has a refresh button', () => {
expect(wrapper.find('[data-test="federation-communities-refresh-btn"]').exists()).toBe(true)
})
it('renders 1 community list item', () => {
expect(wrapper.findAll('.list-group-item').length).toBe(1)
})
describe('cklicking the refresh button', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.find('[data-test="federation-communities-refresh-btn"]').trigger('click')
})
it('calls the API', async () => {
expect(allCommunitiesMock).toBeCalled()
})
})
wrapper = mount(FederationVisualize, {
global: {
mocks: {
$t: (key) => key,
},
stubs: {
BButton,
BListGroup,
BRow,
BCol,
BListGroupItem,
IBiArrowClockwise: true,
'community-visualize-item': true,
},
},
})
})
it('renders the component', () => {
expect(wrapper.find('.federation-visualize').exists()).toBe(true)
})
it('displays the correct header', () => {
expect(wrapper.find('.h2').text()).toBe('federation.gradidoInstances')
})
it('renders the refresh button', () => {
const refreshButton = wrapper.find('[data-test="federation-communities-refresh-btn"]')
expect(refreshButton.exists()).toBe(true)
})
it('calls refetch when refresh button is clicked', async () => {
const refreshButton = wrapper.find('[data-test="federation-communities-refresh-btn"]')
await refreshButton.trigger('click')
expect(mockRefetch).toHaveBeenCalled()
})
it('displays communities when data is loaded', async () => {
const mockCommunities = [
{ publicKey: '1', foreign: true },
{ publicKey: '2', foreign: false },
]
mockResult.value = { allCommunities: mockCommunities }
await nextTick()
const listItems = wrapper.findAllComponents({ name: 'BListGroupItem' })
expect(listItems).toHaveLength(2)
expect(listItems[0].props('variant')).toBe('warning')
expect(listItems[1].props('variant')).toBe('primary')
})
it('shows loading animation when fetching data', async () => {
mockLoading.value = true
await nextTick()
const refreshButton = wrapper.find('[data-test="federation-communities-refresh-btn"]')
expect(refreshButton.attributes('animation')).toBe('spin')
})
it('displays error toast when query fails', async () => {
mockError.value = new Error('Test error')
await nextTick()
expect(mockToastError).toHaveBeenCalledWith('Test error')
})
it('renders correct column headers', () => {
const columns = wrapper.findAll('.list-group > .row > div')
expect(columns[0].text()).toBe('federation.verified')
expect(columns[1].text()).toBe('federation.url')
expect(columns[2].text()).toBe('federation.name')
expect(columns[3].text()).toBe('federation.lastAnnouncedAt')
expect(columns[4].text()).toBe('federation.createdAt')
})
})

View File

@ -2,69 +2,53 @@
<div class="federation-visualize">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="h2">{{ $t('federation.gradidoInstances') }}</span>
<b-button>
<b-icon
icon="arrow-clockwise"
font-scale="2"
:animation="animation"
@click="$apollo.queries.allCommunities.refresh()"
data-test="federation-communities-refresh-btn"
></b-icon>
</b-button>
<BButton
:animation="animation"
data-test="federation-communities-refresh-btn"
font-scale="2"
@click="refetch"
>
<IBiArrowClockwise />
</BButton>
</div>
<b-list-group>
<b-row>
<b-col cols="1" class="ml-1">{{ $t('federation.verified') }}</b-col>
<b-col class="ml-3">{{ $t('federation.url') }}</b-col>
<b-col class="ml-3">{{ $t('federation.name') }}</b-col>
<b-col cols="2">{{ $t('federation.lastAnnouncedAt') }}</b-col>
<b-col cols="2">{{ $t('federation.createdAt') }}</b-col>
</b-row>
<b-list-group-item
<BListGroup>
<BRow>
<BCol cols="1" class="ms-1">{{ $t('federation.verified') }}</BCol>
<BCol class="ms-3">{{ $t('federation.url') }}</BCol>
<BCol class="ms-3">{{ $t('federation.name') }}</BCol>
<BCol cols="2">{{ $t('federation.lastAnnouncedAt') }}</BCol>
<BCol cols="2">{{ $t('federation.createdAt') }}</BCol>
</BRow>
<BListGroupItem
v-for="item in communities"
:key="item.publicKey"
:variant="!item.foreign ? 'primary' : 'warning'"
>
<community-visualize-item :item="item" />
</b-list-group-item>
</b-list-group>
</BListGroupItem>
</BListGroup>
</div>
</template>
<script>
<script setup>
import { computed, watch } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { allCommunities } from '@/graphql/allCommunities'
import { useAppToast } from '@/composables/useToast'
import CommunityVisualizeItem from '../components/Federation/CommunityVisualizeItem.vue'
const { toastError } = useAppToast()
export default {
name: 'FederationVisualize',
components: {
CommunityVisualizeItem,
},
data() {
return {
oldPublicKey: '',
communities: [],
icon: '',
}
},
computed: {
animation() {
return this.$apollo.queries.allCommunities.loading ? 'spin' : ''
},
},
apollo: {
allCommunities: {
fetchPolicy: 'network-only',
query() {
return allCommunities
},
update({ allCommunities }) {
this.communities = allCommunities
},
error({ message }) {
this.toastError(message)
},
},
},
}
const { result, loading, refetch, error } = useQuery(allCommunities, () => ({}), {
fetchPolicy: 'network-only',
})
const communities = computed(() => {
return result.value?.allCommunities || []
})
watch(error, () => {
if (error.value) toastError(error.value.message)
})
const animation = computed(() => (loading.value ? 'spin' : ''))
</script>

Some files were not shown because too many files have changed in this diff Show More