diff --git a/admin/package.json b/admin/package.json index 0cbf57f5a..d499117af 100644 --- a/admin/package.json +++ b/admin/package.json @@ -36,6 +36,7 @@ "graphql": "^15.6.1", "identity-obj-proxy": "^3.0.0", "jest": "26.6.3", + "portal-vue": "^2.1.7", "regenerator-runtime": "^0.13.9", "stats-webpack-plugin": "^0.7.0", "vue": "^2.6.11", @@ -43,7 +44,6 @@ "vue-i18n": "^8.26.5", "vue-jest": "^3.0.7", "vue-router": "^3.5.3", - "vue-toasted": "^1.1.28", "vuex": "^3.6.2", "vuex-persistedstate": "^4.1.0" }, diff --git a/admin/src/components/ConfirmRegisterMailFormular.spec.js b/admin/src/components/ConfirmRegisterMailFormular.spec.js index 78f5791dc..e9e21b31e 100644 --- a/admin/src/components/ConfirmRegisterMailFormular.spec.js +++ b/admin/src/components/ConfirmRegisterMailFormular.spec.js @@ -1,21 +1,17 @@ import { mount } from '@vue/test-utils' import ConfirmRegisterMailFormular from './ConfirmRegisterMailFormular.vue' +import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' + const localVue = global.localVue const apolloMutateMock = jest.fn().mockResolvedValue() -const toastSuccessMock = jest.fn() -const toastErrorMock = jest.fn() const mocks = { $t: jest.fn((t) => t), $apollo: { mutate: apolloMutateMock, }, - $toasted: { - success: toastSuccessMock, - error: toastErrorMock, - }, } const propsData = { @@ -54,7 +50,7 @@ describe('ConfirmRegisterMailFormular', () => { }) it('toasts a success message', () => { - expect(toastSuccessMock).toBeCalledWith('unregister_mail.success') + expect(toastSuccessSpy).toBeCalledWith('unregister_mail.success') }) }) @@ -66,7 +62,7 @@ describe('ConfirmRegisterMailFormular', () => { }) it('toasts an error message', () => { - expect(toastErrorMock).toBeCalledWith('unregister_mail.error') + expect(toastErrorSpy).toBeCalledWith('unregister_mail.error') }) }) }) diff --git a/admin/src/components/ConfirmRegisterMailFormular.vue b/admin/src/components/ConfirmRegisterMailFormular.vue index 067e95c67..1b72f55d0 100644 --- a/admin/src/components/ConfirmRegisterMailFormular.vue +++ b/admin/src/components/ConfirmRegisterMailFormular.vue @@ -48,10 +48,10 @@ export default { }, }) .then(() => { - this.$toasted.success(this.$t('unregister_mail.success', { email: this.email })) + this.toastSuccess(this.$t('unregister_mail.success', { email: this.email })) }) .catch((error) => { - this.$toasted.error(this.$t('unregister_mail.error', { message: error.message })) + this.toastError(this.$t('unregister_mail.error', { message: error.message })) }) }, }, diff --git a/admin/src/components/CreationFormular.spec.js b/admin/src/components/CreationFormular.spec.js index cfc23fa26..083b7ca67 100644 --- a/admin/src/components/CreationFormular.spec.js +++ b/admin/src/components/CreationFormular.spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils' import CreationFormular from './CreationFormular.vue' import { createPendingCreation } from '../graphql/createPendingCreation' import { createPendingCreations } from '../graphql/createPendingCreations' +import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' const localVue = global.localVue @@ -11,8 +12,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({ }, }) const stateCommitMock = jest.fn() -const toastedErrorMock = jest.fn() -const toastedSuccessMock = jest.fn() const mocks = { $t: jest.fn((t, options) => (options ? [t, options] : t)), @@ -32,10 +31,6 @@ const mocks = { }, }, }, - $toasted: { - error: toastedErrorMock, - success: toastedSuccessMock, - }, } const propsData = { @@ -140,7 +135,7 @@ describe('CreationFormular', () => { }) it('toasts a success message', () => { - expect(toastedSuccessMock).toBeCalledWith([ + expect(toastSuccessSpy).toBeCalledWith([ 'creation_form.toasted', { email: 'benjamin@bluemchen.de', value: '90' }, ]) @@ -162,7 +157,7 @@ describe('CreationFormular', () => { }) it('toasts an error message', () => { - expect(toastedErrorMock).toBeCalledWith('Ouch!') + expect(toastErrorSpy).toBeCalledWith('Ouch!') }) }) @@ -292,7 +287,7 @@ describe('CreationFormular', () => { }) it('toast success message', () => { - expect(toastedSuccessMock).toBeCalled() + expect(toastSuccessSpy).toBeCalled() }) it('store commit openCreationPlus', () => { @@ -426,13 +421,14 @@ describe('CreationFormular', () => { expect(stateCommitMock).toBeCalledWith('openCreationsPlus', 0) }) - it('toasts two errors', () => { - expect(toastedErrorMock).toBeCalledWith( - 'Could not created PendingCreation for bob@baumeister.de', - ) - expect(toastedErrorMock).toBeCalledWith( - 'Could not created PendingCreation for bibi@bloxberg.de', - ) + it('emits remove all bookmarks', () => { + expect(wrapper.emitted('remove-all-bookmark')).toBeTruthy() + }) + + it('emits toast failed creations with two emails', () => { + expect(wrapper.emitted('toast-failed-creations')).toEqual([ + [['bob@baumeister.de', 'bibi@bloxberg.de']], + ]) }) }) @@ -454,7 +450,7 @@ describe('CreationFormular', () => { }) it('toasts an error message', () => { - expect(toastedErrorMock).toBeCalledWith('Oh no!') + expect(toastErrorSpy).toBeCalledWith('Oh no!') }) }) }) diff --git a/admin/src/components/CreationFormular.vue b/admin/src/components/CreationFormular.vue index 34df13e11..cd4de5fd6 100644 --- a/admin/src/components/CreationFormular.vue +++ b/admin/src/components/CreationFormular.vue @@ -166,20 +166,21 @@ export default { fetchPolicy: 'no-cache', }) .then((result) => { + const failedCreations = [] this.$store.commit( 'openCreationsPlus', result.data.createPendingCreations.successfulCreation.length, ) if (result.data.createPendingCreations.failedCreation.length > 0) { - result.data.createPendingCreations.failedCreation.forEach((failed) => { - // TODO: Please localize this error message - this.$toasted.error('Could not created PendingCreation for ' + failed) + result.data.createPendingCreations.failedCreation.forEach((email) => { + failedCreations.push(email) }) } this.$emit('remove-all-bookmark') + this.$emit('toast-failed-creations', failedCreations) }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) }) } else if (this.type === 'singleCreation') { submitObj = { @@ -196,19 +197,19 @@ export default { }) .then((result) => { this.$emit('update-user-data', this.item, result.data.createPendingCreation) - this.$toasted.success( + this.$store.commit('openCreationsPlus', 1) + this.toastSuccess( this.$t('creation_form.toasted', { value: this.value, email: this.item.email, }), ) - this.$store.commit('openCreationsPlus', 1) // what is this? Tests says that this.text is not reseted this.$refs.creationForm.reset() this.value = 0 }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) this.$refs.creationForm.reset() this.value = 0 }) diff --git a/admin/src/components/CreationTransactionListFormular.spec.js b/admin/src/components/CreationTransactionListFormular.spec.js index 88cda89ee..7331184b7 100644 --- a/admin/src/components/CreationTransactionListFormular.spec.js +++ b/admin/src/components/CreationTransactionListFormular.spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils' import CreationTransactionListFormular from './CreationTransactionListFormular.vue' +import { toastErrorSpy } from '../../test/testSetup' const localVue = global.localVue @@ -50,17 +51,12 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ }, }) -const toastedErrorMock = jest.fn() - const mocks = { $d: jest.fn((t) => t), $t: jest.fn((t) => t), $apollo: { query: apolloQueryMock, }, - $toasted: { - error: toastedErrorMock, - }, } const propsData = { @@ -109,7 +105,7 @@ describe('CreationTransactionListFormular', () => { }) it('toast error', () => { - expect(toastedErrorMock).toBeCalledWith('OUCH!') + expect(toastErrorSpy).toBeCalledWith('OUCH!') }) }) }) diff --git a/admin/src/components/CreationTransactionListFormular.vue b/admin/src/components/CreationTransactionListFormular.vue index e00c32cb2..2dba0c40f 100644 --- a/admin/src/components/CreationTransactionListFormular.vue +++ b/admin/src/components/CreationTransactionListFormular.vue @@ -63,7 +63,7 @@ export default { this.items = result.data.transactionList.transactions.filter((t) => t.type === 'creation') }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) }) }, }, diff --git a/admin/src/components/DeletedUserFormular.spec.js b/admin/src/components/DeletedUserFormular.spec.js index bad97c1d7..5c41831e5 100644 --- a/admin/src/components/DeletedUserFormular.spec.js +++ b/admin/src/components/DeletedUserFormular.spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils' import DeletedUserFormular from './DeletedUserFormular.vue' import { deleteUser } from '../graphql/deleteUser' import { unDeleteUser } from '../graphql/unDeleteUser' +import { toastErrorSpy } from '../../test/testSetup' const localVue = global.localVue @@ -13,9 +14,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({ }, }) -const toastedErrorMock = jest.fn() -const toastedSuccessMock = jest.fn() - const mocks = { $t: jest.fn((t) => t), $apollo: { @@ -29,10 +27,6 @@ const mocks = { }, }, }, - $toasted: { - error: toastedErrorMock, - success: toastedSuccessMock, - }, } const propsData = { @@ -118,10 +112,6 @@ describe('DeletedUserFormular', () => { ) }) - it('toasts a success message', () => { - expect(toastedSuccessMock).toBeCalledWith('user_deleted') - }) - it('emits update deleted At', () => { expect(wrapper.emitted('updateDeletedAt')).toEqual( expect.arrayContaining([ @@ -147,7 +137,7 @@ describe('DeletedUserFormular', () => { }) it('toasts an error message', () => { - expect(toastedErrorMock).toBeCalledWith('Oh no!') + expect(toastErrorSpy).toBeCalledWith('Oh no!') }) }) @@ -215,10 +205,6 @@ describe('DeletedUserFormular', () => { ) }) - it('toasts a success message', () => { - expect(toastedSuccessMock).toBeCalledWith('user_recovered') - }) - it('emits update deleted At', () => { expect(wrapper.emitted('updateDeletedAt')).toEqual( expect.arrayContaining([ @@ -244,7 +230,7 @@ describe('DeletedUserFormular', () => { }) it('toasts an error message', () => { - expect(toastedErrorMock).toBeCalledWith('Oh no!') + expect(toastErrorSpy).toBeCalledWith('Oh no!') }) }) diff --git a/admin/src/components/DeletedUserFormular.vue b/admin/src/components/DeletedUserFormular.vue index b840fdb23..03359d9f9 100644 --- a/admin/src/components/DeletedUserFormular.vue +++ b/admin/src/components/DeletedUserFormular.vue @@ -45,7 +45,6 @@ export default { }, }) .then((result) => { - this.$toasted.success(this.$t('user_deleted')) this.$emit('updateDeletedAt', { userId: this.item.userId, deletedAt: result.data.deleteUser, @@ -53,7 +52,7 @@ export default { this.checked = false }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) }) }, unDeleteUser() { @@ -65,7 +64,7 @@ export default { }, }) .then((result) => { - this.$toasted.success(this.$t('user_recovered')) + this.toastSuccess(this.$t('user_recovered')) this.$emit('updateDeletedAt', { userId: this.item.userId, deletedAt: result.data.unDeleteUser, @@ -73,7 +72,7 @@ export default { this.checked = false }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) }) }, }, diff --git a/admin/src/components/EditCreationFormular.spec.js b/admin/src/components/EditCreationFormular.spec.js index 84d3e26d3..f5c7fb0fe 100644 --- a/admin/src/components/EditCreationFormular.spec.js +++ b/admin/src/components/EditCreationFormular.spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils' import EditCreationFormular from './EditCreationFormular.vue' +import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' const localVue = global.localVue @@ -16,8 +17,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({ }) const stateCommitMock = jest.fn() -const toastedErrorMock = jest.fn() -const toastedSuccessMock = jest.fn() const mocks = { $t: jest.fn((t) => t), @@ -37,10 +36,6 @@ const mocks = { }, commit: stateCommitMock, }, - $toasted: { - error: toastedErrorMock, - success: toastedSuccessMock, - }, } const now = new Date(Date.now()) @@ -142,7 +137,7 @@ describe('EditCreationFormular', () => { }) it('toasts a success message', () => { - expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_update') + expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_update') }) }) @@ -155,7 +150,7 @@ describe('EditCreationFormular', () => { }) it('toasts an error message', () => { - expect(toastedErrorMock).toBeCalledWith('Oh no!') + expect(toastErrorSpy).toBeCalledWith('Oh no!') }) }) }) diff --git a/admin/src/components/EditCreationFormular.vue b/admin/src/components/EditCreationFormular.vue index 650b00410..82b444154 100644 --- a/admin/src/components/EditCreationFormular.vue +++ b/admin/src/components/EditCreationFormular.vue @@ -132,7 +132,7 @@ export default { moderator: Number(result.data.updatePendingCreation.moderator), row: this.row, }) - this.$toasted.success( + this.toastSuccess( this.$t('creation_form.toasted_update', { value: this.value, email: this.item.email, @@ -144,7 +144,7 @@ export default { this.value = 0 }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) // das creation Formular reseten this.$refs.updateCreationForm.reset() // Den geschöpften Wert auf o setzen diff --git a/admin/src/components/Tables/SearchUserTable.spec.js b/admin/src/components/Tables/SearchUserTable.spec.js index 9e1ce5e52..eb87357cc 100644 --- a/admin/src/components/Tables/SearchUserTable.spec.js +++ b/admin/src/components/Tables/SearchUserTable.spec.js @@ -73,10 +73,6 @@ const mocks = { }, }, }, - $toasted: { - error: jest.fn(), - success: jest.fn(), - }, } describe('SearchUserTable', () => { diff --git a/admin/src/locales/de.json b/admin/src/locales/de.json index e5f4bf4ca..4c85db8db 100644 --- a/admin/src/locales/de.json +++ b/admin/src/locales/de.json @@ -5,6 +5,7 @@ "confirmed": "bestätigt", "creation": "Schöpfung", "creation_form": { + "creation_failed": "Ausstehende Schöpfung für {email} konnte nicht erzeugt werden.", "creation_for": "Aktives Grundeinkommen für", "enter_text": "Text eintragen", "form": "Schöpfungsformular", @@ -28,6 +29,7 @@ "delete_user": "Nutzer löschen", "details": "Details", "edit": "Bearbeiten", + "error": "Fehler", "e_mail": "E-Mail", "firstname": "Vorname", "gradido_admin_footer": "Gradido Akademie Adminkonsole", @@ -68,6 +70,7 @@ "remove_all": "alle Nutzer entfernen", "save": "Speichern", "status": "Status", + "success": "Erfolg", "text": "Text", "transaction": "Transaktion", "transactionlist": { diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index d772d638f..b7a7b5013 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -5,6 +5,7 @@ "confirmed": "confirmed", "creation": "Creation", "creation_form": { + "creation_failed": "Could not create pending creation for {email}", "creation_for": "Active Basic Income for", "enter_text": "Enter text", "form": "Creation form", @@ -28,6 +29,7 @@ "delete_user": "Delete user", "details": "Details", "edit": "Edit", + "error": "Error", "e_mail": "E-mail", "firstname": "Firstname", "gradido_admin_footer": "Gradido Academy Admin Console", @@ -68,6 +70,7 @@ "remove_all": "Remove all users", "save": "Speichern", "status": "Status", + "success": "Success", "text": "Text", "transaction": "Transaction", "transactionlist": { diff --git a/admin/src/main.js b/admin/src/main.js index f4a8dfb3c..f6c021a53 100644 --- a/admin/src/main.js +++ b/admin/src/main.js @@ -13,31 +13,24 @@ 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 Toasted from 'vue-toasted' +import { toasters } from './mixins/toaster' import { apolloProvider } from './plugins/apolloProvider' +Vue.use(PortalVue) Vue.use(BootstrapVue) Vue.use(IconsPlugin) Vue.use(VueApollo) -Vue.use(Toasted, { - position: 'top-center', - duration: 5000, - fullWidth: true, - action: { - text: 'x', - onClick: (e, toastObject) => { - toastObject.goAway(0) - }, - }, -}) +Vue.mixin(toasters) addNavigationGuards(router, store, apolloProvider.defaultClient, i18n) diff --git a/admin/src/mixins/toaster.js b/admin/src/mixins/toaster.js new file mode 100644 index 000000000..9f79b91e8 --- /dev/null +++ b/admin/src/mixins/toaster.js @@ -0,0 +1,30 @@ +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.$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, + }) + }, + }, +} diff --git a/admin/src/pages/Creation.spec.js b/admin/src/pages/Creation.spec.js index 81d556e9b..f9a4ed506 100644 --- a/admin/src/pages/Creation.spec.js +++ b/admin/src/pages/Creation.spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils' import Creation from './Creation.vue' +import { toastErrorSpy } from '../../test/testSetup' const localVue = global.localVue @@ -29,18 +30,14 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ }, }) -const toastErrorMock = jest.fn() const storeCommitMock = jest.fn() const mocks = { - $t: jest.fn((t) => t), + $t: jest.fn((t, options) => (options ? [t, options] : t)), $d: jest.fn((d) => d), $apollo: { query: apolloQueryMock, }, - $toasted: { - error: toastErrorMock, - }, $store: { commit: storeCommitMock, state: { @@ -236,6 +233,25 @@ describe('Creation', () => { }) }) + describe('failed creations', () => { + beforeEach(async () => { + await wrapper + .findComponent({ name: 'CreationFormular' }) + .vm.$emit('toast-failed-creations', ['bibi@bloxberg.de', 'benjamin@bluemchen.de']) + }) + + it('toasts two error messages', () => { + expect(toastErrorSpy).toBeCalledWith([ + 'creation_form.creation_failed', + { email: 'bibi@bloxberg.de' }, + ]) + expect(toastErrorSpy).toBeCalledWith([ + 'creation_form.creation_failed', + { email: 'benjamin@bluemchen.de' }, + ]) + }) + }) + describe('watchers', () => { beforeEach(() => { jest.clearAllMocks() @@ -298,7 +314,7 @@ describe('Creation', () => { }) it('toasts an error message', () => { - expect(toastErrorMock).toBeCalledWith('Ouch') + expect(toastErrorSpy).toBeCalledWith('Ouch') }) }) }) diff --git a/admin/src/pages/Creation.vue b/admin/src/pages/Creation.vue index 16678cad8..a5966ee68 100644 --- a/admin/src/pages/Creation.vue +++ b/admin/src/pages/Creation.vue @@ -56,6 +56,7 @@ :creation="creation" :items="itemsMassCreation" @remove-all-bookmark="removeAllBookmarks" + @toast-failed-creations="toastFailedCreations" /> @@ -118,7 +119,7 @@ export default { } }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) }) }, pushItem(selectedItem) { @@ -144,6 +145,11 @@ export default { this.$store.commit('setUserSelectedInMassCreation', []) this.getUsers() }, + toastFailedCreations(failedCreations) { + failedCreations.forEach((email) => + this.toastError(this.$t('creation_form.creation_failed', { email })), + ) + }, }, computed: { Searchfields() { diff --git a/admin/src/pages/CreationConfirm.spec.js b/admin/src/pages/CreationConfirm.spec.js index f0412678b..6df60378c 100644 --- a/admin/src/pages/CreationConfirm.spec.js +++ b/admin/src/pages/CreationConfirm.spec.js @@ -2,12 +2,11 @@ import { mount } from '@vue/test-utils' import CreationConfirm from './CreationConfirm.vue' import { deletePendingCreation } from '../graphql/deletePendingCreation' import { confirmPendingCreation } from '../graphql/confirmPendingCreation' +import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' const localVue = global.localVue const storeCommitMock = jest.fn() -const toastedErrorMock = jest.fn() -const toastedSuccessMock = jest.fn() const apolloQueryMock = jest.fn().mockResolvedValue({ data: { getPendingCreations: [ @@ -47,10 +46,6 @@ const mocks = { query: apolloQueryMock, mutate: apolloMutateMock, }, - $toasted: { - error: toastedErrorMock, - success: toastedSuccessMock, - }, } describe('CreationConfirm', () => { @@ -101,7 +96,7 @@ describe('CreationConfirm', () => { }) it('toasts a success message', () => { - expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_delete') + expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete') }) }) @@ -112,7 +107,7 @@ describe('CreationConfirm', () => { }) it('toasts an error message', () => { - expect(toastedErrorMock).toBeCalledWith('Ouchhh!') + expect(toastErrorSpy).toBeCalledWith('Ouchhh!') }) }) @@ -158,7 +153,7 @@ describe('CreationConfirm', () => { }) it('toasts a success message', () => { - expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_created') + expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_created') }) it('has 1 item left in the table', () => { @@ -173,7 +168,7 @@ describe('CreationConfirm', () => { }) it('toasts an error message', () => { - expect(toastedErrorMock).toBeCalledWith('Ouchhh!') + expect(toastErrorSpy).toBeCalledWith('Ouchhh!') }) }) }) @@ -189,7 +184,7 @@ describe('CreationConfirm', () => { }) it('toast an error message', () => { - expect(toastedErrorMock).toBeCalledWith('Ouch!') + expect(toastErrorSpy).toBeCalledWith('Ouch!') }) }) }) diff --git a/admin/src/pages/CreationConfirm.vue b/admin/src/pages/CreationConfirm.vue index 54580c366..26928fb67 100644 --- a/admin/src/pages/CreationConfirm.vue +++ b/admin/src/pages/CreationConfirm.vue @@ -43,10 +43,10 @@ export default { }) .then((result) => { this.updatePendingCreations(item.id) - this.$toasted.success(this.$t('creation_form.toasted_delete')) + this.toastSuccess(this.$t('creation_form.toasted_delete')) }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) }) }, confirmCreation() { @@ -60,11 +60,11 @@ export default { .then((result) => { this.overlay = false this.updatePendingCreations(this.item.id) - this.$toasted.success(this.$t('creation_form.toasted_created')) + this.toastSuccess(this.$t('creation_form.toasted_created')) }) .catch((error) => { this.overlay = false - this.$toasted.error(error.message) + this.toastError(error.message) }) }, getPendingCreations() { @@ -79,7 +79,7 @@ export default { this.$store.commit('setOpenCreations', result.data.getPendingCreations.length) }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) }) }, updatePendingCreations(id) { diff --git a/admin/src/pages/UserSearch.spec.js b/admin/src/pages/UserSearch.spec.js index ab2bd722f..bd18965ac 100644 --- a/admin/src/pages/UserSearch.spec.js +++ b/admin/src/pages/UserSearch.spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils' import UserSearch from './UserSearch.vue' +import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' const localVue = global.localVue @@ -15,6 +16,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ email: 'bibi@bloxberg.de', creation: [200, 400, 600], emailChecked: true, + deletedAt: null, }, { userId: 2, @@ -23,6 +25,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ email: 'benjamin@bluemchen.de', creation: [1000, 1000, 1000], emailChecked: true, + deletedAt: null, }, { userId: 3, @@ -31,6 +34,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ email: 'peter@lustig.de', creation: [0, 0, 0], emailChecked: true, + deletedAt: null, }, { userId: 4, @@ -39,23 +43,19 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ email: 'new@user.ch', creation: [1000, 1000, 1000], emailChecked: false, + deletedAt: null, }, ], }, }, }) -const toastErrorMock = jest.fn() - const mocks = { $t: jest.fn((t) => t), $d: jest.fn((d) => String(d)), $apollo: { query: apolloQueryMock, }, - $toasted: { - error: toastErrorMock, - }, } describe('UserSearch', () => { @@ -187,6 +187,21 @@ describe('UserSearch', () => { }) }) + describe('delete user', () => { + const now = new Date() + beforeEach(async () => { + wrapper.findComponent({ name: 'SearchUserTable' }).vm.$emit('updateDeletedAt', 4, now) + }) + + it('marks the user as deleted', () => { + expect(wrapper.vm.searchResult.find((obj) => obj.userId === 4).deletedAt).toEqual(now) + }) + + it('toasts a success message', () => { + expect(toastSuccessSpy).toBeCalledWith('user_deleted') + }) + }) + describe('apollo returns error', () => { beforeEach(() => { apolloQueryMock.mockRejectedValue({ @@ -196,7 +211,7 @@ describe('UserSearch', () => { }) it('toasts an error message', () => { - expect(toastErrorMock).toBeCalledWith('Ouch') + expect(toastErrorSpy).toBeCalledWith('Ouch') }) }) }) diff --git a/admin/src/pages/UserSearch.vue b/admin/src/pages/UserSearch.vue index edd64445c..ea49bf805 100644 --- a/admin/src/pages/UserSearch.vue +++ b/admin/src/pages/UserSearch.vue @@ -94,11 +94,12 @@ export default { this.searchResult = result.data.searchUsers.userList }) .catch((error) => { - this.$toasted.error(error.message) + this.toastError(error.message) }) }, updateDeletedAt(userId, deletedAt) { this.searchResult.find((obj) => obj.userId === userId).deletedAt = deletedAt + this.toastSuccess(this.$t('user_deleted')) }, }, watch: { diff --git a/admin/test/testSetup.js b/admin/test/testSetup.js index caaa3c19c..df3a025da 100644 --- a/admin/test/testSetup.js +++ b/admin/test/testSetup.js @@ -5,11 +5,18 @@ import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' // without this async calls are not working import 'regenerator-runtime' +import { toasters } from '../src/mixins/toaster' + +export const toastErrorSpy = jest.spyOn(toasters.methods, 'toastError') +export const toastSuccessSpy = jest.spyOn(toasters.methods, 'toastSuccess') + global.localVue = createLocalVue() global.localVue.use(BootstrapVue) global.localVue.use(IconsPlugin) +global.localVue.mixin(toasters) + // throw errors for vue warnings to force the programmers to take care about warnings Vue.config.warnHandler = (w) => { throw new Error(w) diff --git a/admin/yarn.lock b/admin/yarn.lock index aff8f0d0b..4e5d587e5 100644 --- a/admin/yarn.lock +++ b/admin/yarn.lock @@ -12512,11 +12512,6 @@ vue-template-es2015-compiler@^1.6.0, vue-template-es2015-compiler@^1.9.0: resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== -vue-toasted@^1.1.28: - version "1.1.28" - resolved "https://registry.yarnpkg.com/vue-toasted/-/vue-toasted-1.1.28.tgz#dbabb83acc89f7a9e8765815e491d79f0dc65c26" - integrity sha512-UUzr5LX51UbbiROSGZ49GOgSzFxaMHK6L00JV8fir/CYNJCpIIvNZ5YmS4Qc8Y2+Z/4VVYRpeQL2UO0G800Raw== - vue@^2.6.11: version "2.6.14" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235" diff --git a/backend/src/graphql/model/Balance.ts b/backend/src/graphql/model/Balance.ts index aaeecd0d7..2f1eeb406 100644 --- a/backend/src/graphql/model/Balance.ts +++ b/backend/src/graphql/model/Balance.ts @@ -17,6 +17,6 @@ export class Balance { @Field(() => Decimal) decay: Decimal - @Field(() => String) - decayDate: string + @Field(() => Date) + decayDate: Date } diff --git a/backend/src/graphql/model/Decay.ts b/backend/src/graphql/model/Decay.ts index a56be6ff3..f1204e730 100644 --- a/backend/src/graphql/model/Decay.ts +++ b/backend/src/graphql/model/Decay.ts @@ -1,6 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { ObjectType, Field } from 'type-graphql' +import { ObjectType, Field, Int } from 'type-graphql' import Decimal from 'decimal.js-light' @ObjectType() @@ -31,6 +29,6 @@ export class Decay { @Field(() => Date, { nullable: true }) end: Date | null - @Field(() => Number, { nullable: true }) + @Field(() => Int, { nullable: true }) duration: number | null } diff --git a/backend/src/graphql/model/Transaction.ts b/backend/src/graphql/model/Transaction.ts index 3e8089eda..3cec757e2 100644 --- a/backend/src/graphql/model/Transaction.ts +++ b/backend/src/graphql/model/Transaction.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { ObjectType, Field } from 'type-graphql' import { Decay } from './Decay' import { Transaction as dbTransaction } from '@entity/Transaction' @@ -25,7 +23,7 @@ export class Transaction { transaction.decay, transaction.decayStart, transaction.balanceDate, - (transaction.balanceDate.getTime() - transaction.decayStart.getTime()) / 1000, + Math.round((transaction.balanceDate.getTime() - transaction.decayStart.getTime()) / 1000), ) } this.memo = transaction.memo diff --git a/backend/src/graphql/model/TransactionList.ts b/backend/src/graphql/model/TransactionList.ts index d4fcb65eb..2f3d1d080 100644 --- a/backend/src/graphql/model/TransactionList.ts +++ b/backend/src/graphql/model/TransactionList.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { ObjectType, Field } from 'type-graphql' import CONFIG from '../../config' import Decimal from 'decimal.js-light' diff --git a/backend/src/graphql/model/User.ts b/backend/src/graphql/model/User.ts index c23ea0a58..1a187a38f 100644 --- a/backend/src/graphql/model/User.ts +++ b/backend/src/graphql/model/User.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { ObjectType, Field } from 'type-graphql' import { KlickTipp } from './KlickTipp' import { User as dbUser } from '@entity/User' diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 84143c5f6..3ce052be4 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -307,13 +307,13 @@ export class AdminResolver { const receivedCallDate = new Date() const transactionRepository = getCustomRepository(TransactionRepository) - const lastUserTransaction = await transactionRepository.findLastForUser(pendingCreation.userId) + const lastTransaction = await transactionRepository.findLastForUser(pendingCreation.userId) let newBalance = new Decimal(0) - if (lastUserTransaction) { + if (lastTransaction) { newBalance = calculateDecay( - lastUserTransaction.balance, - lastUserTransaction.balanceDate, + lastTransaction.balance, + lastTransaction.balanceDate, receivedCallDate, ).balance } diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 63166cb08..1451609ad 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -30,7 +30,8 @@ import { RIGHTS } from '../../auth/RIGHTS' import { User } from '../model/User' import { communityUser } from '../../util/communityUser' import { virtualDecayTransaction } from '../../util/virtualDecayTransaction' -import Decimal from '../scalar/Decimal' +import Decimal from 'decimal.js-light' +import { calculateDecay } from '../../util/decay' @Resolver() export class TransactionResolver { @@ -47,6 +48,7 @@ export class TransactionResolver { }: Paginated, @Ctx() context: any, ): Promise { + const now = new Date() // find user const userRepository = getCustomRepository(UserRepository) // TODO: separate those usecases - this is a security issue @@ -60,65 +62,6 @@ export class TransactionResolver { { order: { balanceDate: 'DESC' } }, ) - if (!lastTransaction) { - // TODO Have proper return type here - throw new Error('User has no transactions') - } - - // find transactions - const limit = currentPage === 1 && order === Order.DESC ? pageSize - 1 : pageSize - const offset = - currentPage === 1 ? 0 : (currentPage - 1) * pageSize - (order === Order.DESC ? 1 : 0) - const transactionRepository = getCustomRepository(TransactionRepository) - const [userTransactions, userTransactionsCount] = await transactionRepository.findByUserPaged( - user.id, - limit, - offset, - order, - onlyCreations, - ) - - // find involved users - let involvedUserIds: number[] = [] - userTransactions.forEach((transaction: dbTransaction) => { - involvedUserIds.push(transaction.userId) - if (transaction.linkedUserId) { - involvedUserIds.push(transaction.linkedUserId) - } - }) - // remove duplicates - involvedUserIds = involvedUserIds.filter((value, index, self) => self.indexOf(value) === index) - // We need to show the name for deleted users for old transactions - const involvedDbUsers = await dbUser - .createQueryBuilder() - .withDeleted() - .where('id IN (:...userIds)', { userIds: involvedUserIds }) - .getMany() - const involvedUsers = involvedDbUsers.map((u) => new User(u)) - - const self = new User(user) - const transactions: Transaction[] = [] - - // decay transaction - if (currentPage === 1 && order === Order.DESC) { - const now = new Date() - transactions.push( - virtualDecayTransaction(lastTransaction.balance, lastTransaction.balanceDate, now, self), - ) - } - - // transactions - for (let i = 0; i < userTransactions.length; i++) { - const userTransaction = userTransactions[i] - let linkedUser = null - if (userTransaction.typeId === TypeId.CREATION) { - linkedUser = communityUser - } else { - linkedUser = involvedUsers.find((u) => u.id === userTransaction.linkedUserId) - } - transactions.push(new Transaction(userTransaction, self, linkedUser)) - } - // get GDT let balanceGDT = null try { @@ -134,9 +77,59 @@ export class TransactionResolver { console.log('Could not query GDT Server', err) } + if (!lastTransaction) { + return new TransactionList(new Decimal(0), [], 0, balanceGDT) + } + + // find transactions + // first page can contain 26 due to virtual decay transaction + const offset = (currentPage - 1) * pageSize + const transactionRepository = getCustomRepository(TransactionRepository) + const [userTransactions, userTransactionsCount] = await transactionRepository.findByUserPaged( + user.id, + pageSize, + offset, + order, + onlyCreations, + ) + + // find involved users; I am involved + const involvedUserIds: number[] = [user.id] + userTransactions.forEach((transaction: dbTransaction) => { + if (transaction.linkedUserId && !involvedUserIds.includes(transaction.linkedUserId)) { + involvedUserIds.push(transaction.linkedUserId) + } + }) + // We need to show the name for deleted users for old transactions + const involvedDbUsers = await dbUser + .createQueryBuilder() + .withDeleted() + .where('id IN (:...userIds)', { userIds: involvedUserIds }) + .getMany() + const involvedUsers = involvedDbUsers.map((u) => new User(u)) + + const self = new User(user) + const transactions: Transaction[] = [] + + // decay transaction + if (currentPage === 1 && order === Order.DESC) { + transactions.push( + virtualDecayTransaction(lastTransaction.balance, lastTransaction.balanceDate, now, self), + ) + } + + // transactions + userTransactions.forEach((userTransaction) => { + const linkedUser = + userTransaction.typeId === TransactionTypeId.CREATION + ? communityUser + : involvedUsers.find((u) => u.id === userTransaction.linkedUserId) + transactions.push(new Transaction(userTransaction, self, linkedUser)) + }) + // Construct Result return new TransactionList( - lastTransaction.balance, + calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance, transactions, userTransactionsCount, balanceGDT, @@ -184,7 +177,7 @@ export class TransactionResolver { transactionSend.memo = memo transactionSend.userId = senderUser.id transactionSend.linkedUserId = recipientUser.id - transactionSend.amount = amount + transactionSend.amount = amount.mul(-1) transactionSend.balance = sendBalance.balance transactionSend.balanceDate = receivedCallDate transactionSend.decay = sendBalance.decay.decay diff --git a/backend/src/util/decay.test.ts b/backend/src/util/decay.test.ts index 1653376c1..f1111fab4 100644 --- a/backend/src/util/decay.test.ts +++ b/backend/src/util/decay.test.ts @@ -7,36 +7,35 @@ describe('utils/decay', () => { it('has base 0.99999997802044727', () => { const amount = new Decimal(1.0) const seconds = 1 - expect(decayFormula(amount, seconds)).toBe(0.99999997802044727) - }) - // Not sure if the following skiped tests make sence!? - it('has negative decay?', async () => { - const amount = new Decimal(1.0) - const seconds = 1 - expect(decayFormula(amount, seconds)).toBe(-0.99999997802044727) + // TODO: toString() was required, we could not compare two decimals + expect(decayFormula(amount, seconds).toString()).toBe('0.999999978035040489732012') }) it('has correct backward calculation', async () => { const amount = new Decimal(1.0) const seconds = -1 - expect(decayFormula(amount, seconds)).toBe(1.0000000219795533) + expect(decayFormula(amount, seconds).toString()).toBe('1.000000021964959992727444') }) - // not possible, nodejs hasn't enough accuracy - it('has correct forward calculation', async () => { - const amount = new Decimal(1.0).div(0.99999997802044727) + // we get pretty close, but not exact here, skipping + it.skip('has correct forward calculation', async () => { + const amount = new Decimal(1.0).div( + new Decimal('0.99999997803504048973201202316767079413460520837376'), + ) const seconds = 1 - expect(decayFormula(amount, seconds)).toBe(1.0) + expect(decayFormula(amount, seconds).toString()).toBe('1.0') }) }) - it.skip('has base 0.99999997802044727', async () => { + it('has base 0.99999997802044727', async () => { const now = new Date() now.setSeconds(1) const oneSecondAgo = new Date(now.getTime()) oneSecondAgo.setSeconds(0) - expect(calculateDecay(new Decimal(1.0), oneSecondAgo, now)).toBe(0.99999997802044727) + expect(calculateDecay(new Decimal(1.0), oneSecondAgo, now).balance.toString()).toBe( + '0.999999978035040489732012', + ) }) it('returns input amount when from and to is the same', async () => { const now = new Date() - expect(calculateDecay(new Decimal(100.0), now, now).balance).toBe(100.0) + expect(calculateDecay(new Decimal(100.0), now, now).balance.toString()).toBe('100') }) }) diff --git a/database/src/factories/transaction.factory.ts b/database/src/factories/transaction.factory.ts index 15efa97c2..dd98a88b7 100644 --- a/database/src/factories/transaction.factory.ts +++ b/database/src/factories/transaction.factory.ts @@ -2,6 +2,7 @@ import Faker from 'faker' import { define } from 'typeorm-seeding' import { Transaction } from '../../entity/Transaction' import { TransactionContext } from '../interface/TransactionContext' +import Decimal from 'decimal.js-light' define(Transaction, (faker: typeof Faker, context?: TransactionContext) => { if (!context) { @@ -12,6 +13,8 @@ define(Transaction, (faker: typeof Faker, context?: TransactionContext) => { transaction.typeId = context.typeId // || 2 transaction.userId = context.userId transaction.amount = context.amount + transaction.balance = context.balance + transaction.decay = new Decimal(0) // context.decay transaction.memo = context.memo transaction.creationDate = context.creationDate || new Date() // transaction.sendReceiverPublicKey = context.sendReceiverPublicKey || null