mirror of
https://github.com/IT4Change/gradido.git
synced 2025-12-13 07:45:54 +00:00
Merge remote-tracking branch 'origin/master' into
1798-feature-gradidoid-1-adapt-and-migrate-database-schema
This commit is contained in:
commit
9ff2105fd4
@ -28,7 +28,7 @@ const propsData = {
|
||||
amount: 210,
|
||||
memo: 'Aktives Grundeinkommen für Januar 2022',
|
||||
date: '2022-01-01T00:00:00.000Z',
|
||||
moderator: 1,
|
||||
moderator: null,
|
||||
creation: [790, 1000, 1000],
|
||||
__typename: 'PendingCreation',
|
||||
},
|
||||
@ -66,7 +66,7 @@ const propsData = {
|
||||
},
|
||||
},
|
||||
{ key: 'moderator', label: 'moderator' },
|
||||
{ key: 'edit_creation', label: 'edit' },
|
||||
{ key: 'editCreation', label: 'edit' },
|
||||
{ key: 'confirm', label: 'save' },
|
||||
],
|
||||
toggleDetails: false,
|
||||
@ -113,6 +113,10 @@ describe('OpenCreationsTable', () => {
|
||||
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')
|
||||
|
||||
@ -11,8 +11,14 @@
|
||||
<b-icon icon="x" variant="light"></b-icon>
|
||||
</b-button>
|
||||
</template>
|
||||
<template #cell(edit_creation)="row">
|
||||
<b-button variant="info" size="md" @click="rowToggleDetails(row, 0)" class="mr-2">
|
||||
<template #cell(editCreation)="row">
|
||||
<b-button
|
||||
v-if="row.item.moderator"
|
||||
variant="info"
|
||||
size="md"
|
||||
@click="rowToggleDetails(row, 0)"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon>
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
"creation_form": {
|
||||
"creation_failed": "Ausstehende Schöpfung für {email} konnte nicht erzeugt werden.",
|
||||
"creation_for": "Aktives Grundeinkommen für",
|
||||
"deleteNow": "Möchtest du diesen Beitrag zur Gemeinschaft wirklich löschen?",
|
||||
"enter_text": "Text eintragen",
|
||||
"form": "Schöpfungsformular",
|
||||
"min_characters": "Mindestens 10 Zeichen eingeben",
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
"creation_form": {
|
||||
"creation_failed": "Could not create pending creation for {email}",
|
||||
"creation_for": "Active Basic Income for",
|
||||
"deleteNow": "Do you really want to delete this contribution to the community?",
|
||||
"enter_text": "Enter text",
|
||||
"form": "Creation form",
|
||||
"min_characters": "Enter at least 10 characters",
|
||||
|
||||
@ -18,7 +18,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
amount: 500,
|
||||
memo: 'Danke für alles',
|
||||
date: new Date(),
|
||||
moderator: 0,
|
||||
moderator: 2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@ -28,7 +28,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
amount: 1000000,
|
||||
memo: 'Gut Ergattert',
|
||||
date: new Date(),
|
||||
moderator: 0,
|
||||
moderator: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -80,28 +80,54 @@ describe('CreationConfirm', () => {
|
||||
})
|
||||
|
||||
describe('remove creation with success', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||
})
|
||||
let spy
|
||||
|
||||
it('calls the adminDeleteContribution mutation', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith({
|
||||
mutation: adminDeleteContribution,
|
||||
variables: { id: 1 },
|
||||
describe('admin confirms deletion', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve('some value'))
|
||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('opens a modal', () => {
|
||||
expect(spy).toBeCalled()
|
||||
})
|
||||
|
||||
it('calls the adminDeleteContribution mutation', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith({
|
||||
mutation: adminDeleteContribution,
|
||||
variables: { 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('commits openCreationsMinus to store', () => {
|
||||
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
|
||||
})
|
||||
describe('admin cancels deletion', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(false))
|
||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
|
||||
it('does not call the adminDeleteContribution mutation', () => {
|
||||
expect(apolloMutateMock).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove creation with error', () => {
|
||||
let spy
|
||||
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve('some value'))
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' })
|
||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
@ -34,20 +34,23 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
removeCreation(item) {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: adminDeleteContribution,
|
||||
variables: {
|
||||
id: item.id,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.updatePendingCreations(item.id)
|
||||
this.toastSuccess(this.$t('creation_form.toasted_delete'))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
this.$bvModal.msgBoxConfirm(this.$t('creation_form.deleteNow')).then(async (value) => {
|
||||
if (value)
|
||||
await this.$apollo
|
||||
.mutate({
|
||||
mutation: adminDeleteContribution,
|
||||
variables: {
|
||||
id: item.id,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.updatePendingCreations(item.id)
|
||||
this.toastSuccess(this.$t('creation_form.toasted_delete'))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
})
|
||||
},
|
||||
confirmCreation() {
|
||||
this.$apollo
|
||||
@ -114,7 +117,7 @@ export default {
|
||||
},
|
||||
},
|
||||
{ key: 'moderator', label: this.$t('moderator') },
|
||||
{ key: 'edit_creation', label: this.$t('edit') },
|
||||
{ key: 'editCreation', label: this.$t('edit') },
|
||||
{ key: 'confirm', label: this.$t('save') },
|
||||
]
|
||||
},
|
||||
|
||||
@ -10,7 +10,7 @@ Decimal.set({
|
||||
})
|
||||
|
||||
const constants = {
|
||||
DB_VERSION: '0044-adapt_users_table_for_gradidoid',
|
||||
DB_VERSION: '0045-adapt_users_table_for_gradidoid',
|
||||
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
|
||||
LOG4JS_CONFIG: 'log4js-config.json',
|
||||
// default log level on production should be info
|
||||
|
||||
@ -54,11 +54,11 @@ import {
|
||||
updateCreations,
|
||||
} from './util/creations'
|
||||
import {
|
||||
CONTRIBUTIONLINK_MEMO_MAX_CHARS,
|
||||
CONTRIBUTIONLINK_MEMO_MIN_CHARS,
|
||||
CONTRIBUTIONLINK_NAME_MAX_CHARS,
|
||||
CONTRIBUTIONLINK_NAME_MIN_CHARS,
|
||||
FULL_CREATION_AVAILABLE,
|
||||
MEMO_MAX_CHARS,
|
||||
MEMO_MIN_CHARS,
|
||||
} from './const/const'
|
||||
|
||||
// const EMAIL_OPT_IN_REGISTER = 1
|
||||
@ -595,11 +595,8 @@ export class AdminResolver {
|
||||
logger.error(`The memo must be initialized!`)
|
||||
throw new Error(`The memo must be initialized!`)
|
||||
}
|
||||
if (
|
||||
memo.length < CONTRIBUTIONLINK_MEMO_MIN_CHARS ||
|
||||
memo.length > CONTRIBUTIONLINK_MEMO_MAX_CHARS
|
||||
) {
|
||||
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_MEMO_MIN_CHARS} and max=${CONTRIBUTIONLINK_MEMO_MAX_CHARS}`
|
||||
if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) {
|
||||
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}`
|
||||
logger.error(`${msg}`)
|
||||
throw new Error(`${msg}`)
|
||||
}
|
||||
|
||||
@ -66,6 +66,42 @@ describe('ContributionResolver', () => {
|
||||
})
|
||||
|
||||
describe('input not valid', () => {
|
||||
it('throws error when memo length smaller than 5 chars', async () => {
|
||||
const date = new Date()
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createContribution,
|
||||
variables: {
|
||||
amount: 100.0,
|
||||
memo: 'Test',
|
||||
creationDate: date.toString(),
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws error when memo length greater than 255 chars', async () => {
|
||||
const date = new Date()
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createContribution,
|
||||
variables: {
|
||||
amount: 100.0,
|
||||
memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test',
|
||||
creationDate: date.toString(),
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws error when creationDate not-valid', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
@ -313,6 +349,48 @@ describe('ContributionResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memo length smaller than 5 chars', () => {
|
||||
it('throws error', async () => {
|
||||
const date = new Date()
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: updateContribution,
|
||||
variables: {
|
||||
contributionId: result.data.createContribution.id,
|
||||
amount: 100.0,
|
||||
memo: 'Test',
|
||||
creationDate: date.toString(),
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memo length greater than 255 chars', () => {
|
||||
it('throws error', async () => {
|
||||
const date = new Date()
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: updateContribution,
|
||||
variables: {
|
||||
contributionId: result.data.createContribution.id,
|
||||
amount: 100.0,
|
||||
memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test',
|
||||
creationDate: date.toString(),
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrong user tries to update the contribution', () => {
|
||||
beforeAll(async () => {
|
||||
await query({
|
||||
|
||||
@ -11,6 +11,7 @@ import { Contribution, ContributionListResult } from '@model/Contribution'
|
||||
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
||||
import { User } from '@model/User'
|
||||
import { validateContribution, getUserCreation, updateCreations } from './util/creations'
|
||||
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
|
||||
|
||||
@Resolver()
|
||||
export class ContributionResolver {
|
||||
@ -20,6 +21,16 @@ export class ContributionResolver {
|
||||
@Args() { amount, memo, creationDate }: ContributionArgs,
|
||||
@Ctx() context: Context,
|
||||
): Promise<UnconfirmedContribution> {
|
||||
if (memo.length > MEMO_MAX_CHARS) {
|
||||
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`)
|
||||
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
|
||||
}
|
||||
|
||||
if (memo.length < MEMO_MIN_CHARS) {
|
||||
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`)
|
||||
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
|
||||
}
|
||||
|
||||
const user = getUser(context)
|
||||
const creations = await getUserCreation(user.id)
|
||||
logger.trace('creations', creations)
|
||||
@ -119,6 +130,16 @@ export class ContributionResolver {
|
||||
@Args() { amount, memo, creationDate }: ContributionArgs,
|
||||
@Ctx() context: Context,
|
||||
): Promise<UnconfirmedContribution> {
|
||||
if (memo.length > MEMO_MAX_CHARS) {
|
||||
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`)
|
||||
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
|
||||
}
|
||||
|
||||
if (memo.length < MEMO_MIN_CHARS) {
|
||||
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`)
|
||||
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
|
||||
}
|
||||
|
||||
const user = getUser(context)
|
||||
|
||||
const contributionToUpdate = await dbContribution.findOne({
|
||||
|
||||
@ -34,9 +34,7 @@ import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualT
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
import { BalanceResolver } from './BalanceResolver'
|
||||
|
||||
const MEMO_MAX_CHARS = 255
|
||||
const MEMO_MIN_CHARS = 5
|
||||
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
|
||||
|
||||
export const executeTransaction = async (
|
||||
amount: Decimal,
|
||||
|
||||
@ -8,5 +8,5 @@ export const FULL_CREATION_AVAILABLE = [
|
||||
]
|
||||
export const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100
|
||||
export const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5
|
||||
export const CONTRIBUTIONLINK_MEMO_MAX_CHARS = 255
|
||||
export const CONTRIBUTIONLINK_MEMO_MIN_CHARS = 5
|
||||
export const MEMO_MAX_CHARS = 255
|
||||
export const MEMO_MIN_CHARS = 5
|
||||
|
||||
@ -35,12 +35,17 @@ export const creationFactory = async (
|
||||
if (creation.confirmed) {
|
||||
await mutate({ mutation: confirmContribution, variables: { id: pendingCreation.id } })
|
||||
|
||||
const confirmedCreation = await Contribution.findOneOrFail({ id: pendingCreation.id })
|
||||
|
||||
if (creation.moveCreationDate) {
|
||||
const transaction = await Transaction.findOneOrFail({
|
||||
where: { userId: user.id, creationDate: new Date(creation.creationDate) },
|
||||
order: { balanceDate: 'DESC' },
|
||||
})
|
||||
if (transaction.decay.equals(0) && transaction.creationDate) {
|
||||
confirmedCreation.contributionDate = new Date(
|
||||
nMonthsBefore(transaction.creationDate, creation.moveCreationDate),
|
||||
)
|
||||
transaction.creationDate = new Date(
|
||||
nMonthsBefore(transaction.creationDate, creation.moveCreationDate),
|
||||
)
|
||||
@ -48,6 +53,7 @@ export const creationFactory = async (
|
||||
nMonthsBefore(transaction.balanceDate, creation.moveCreationDate),
|
||||
)
|
||||
await transaction.save()
|
||||
await confirmedCreation.save()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
34
database/migrations/0044-insert_missing_contributions.ts
Normal file
34
database/migrations/0044-insert_missing_contributions.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/* MIGRATION TO INSERT contributions for all transactions with type creation that do not have a contribution yet */
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn(
|
||||
`INSERT INTO gradido_community.contributions
|
||||
(user_id, created_at, contribution_date, memo, amount, moderator_id, confirmed_by, confirmed_at, transaction_id)
|
||||
SELECT
|
||||
user_id,
|
||||
balance_date,
|
||||
creation_date AS contribution_date,
|
||||
memo,
|
||||
amount,
|
||||
20 AS moderator_id,
|
||||
502 AS confirmed_by,
|
||||
balance_date AS confirmed_at,
|
||||
id
|
||||
FROM
|
||||
gradido_community.transactions
|
||||
WHERE
|
||||
type_id = 1
|
||||
AND NOT EXISTS(
|
||||
SELECT * FROM gradido_community.contributions
|
||||
WHERE gradido_community.contributions.transaction_id = gradido_community.transactions.id);`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn(
|
||||
'DELETE FROM `contributions` WHERE `contributions`.`moderator_id` = 20 AND `contributions`.`confirmed_by` = 502 AND `contributions`.`created_at` = `contributions`.`confirmed_at`;',
|
||||
)
|
||||
}
|
||||
@ -122,3 +122,125 @@ volumes:
|
||||
backend_database_build:
|
||||
database_node_modules:
|
||||
database_build:
|
||||
version: "3.4"
|
||||
|
||||
services:
|
||||
|
||||
########################################################
|
||||
# FRONTEND #############################################
|
||||
########################################################
|
||||
frontend:
|
||||
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
|
||||
image: gradido/frontend:local-development
|
||||
build:
|
||||
target: development
|
||||
environment:
|
||||
- NODE_ENV="development"
|
||||
# - DEBUG=true
|
||||
volumes:
|
||||
# This makes sure the docker container has its own node modules.
|
||||
# Therefore it is possible to have a different node version on the host machine
|
||||
- frontend_node_modules:/app/node_modules
|
||||
# bind the local folder to the docker to allow live reload
|
||||
- ./frontend:/app
|
||||
|
||||
########################################################
|
||||
# ADMIN INTERFACE ######################################
|
||||
########################################################
|
||||
admin:
|
||||
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
|
||||
image: gradido/admin:local-development
|
||||
build:
|
||||
target: development
|
||||
environment:
|
||||
- NODE_ENV="development"
|
||||
# - DEBUG=true
|
||||
volumes:
|
||||
# This makes sure the docker container has its own node modules.
|
||||
# Therefore it is possible to have a different node version on the host machine
|
||||
- admin_node_modules:/app/node_modules
|
||||
# bind the local folder to the docker to allow live reload
|
||||
- ./admin:/app
|
||||
|
||||
########################################################
|
||||
# BACKEND ##############################################
|
||||
########################################################
|
||||
backend:
|
||||
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
|
||||
image: gradido/backend:local-development
|
||||
build:
|
||||
target: development
|
||||
networks:
|
||||
- external-net
|
||||
- internal-net
|
||||
environment:
|
||||
- NODE_ENV="development"
|
||||
volumes:
|
||||
# This makes sure the docker container has its own node modules.
|
||||
# Therefore it is possible to have a different node version on the host machine
|
||||
- backend_node_modules:/app/node_modules
|
||||
- backend_database_node_modules:/database/node_modules
|
||||
- backend_database_build:/database/build
|
||||
# bind the local folder to the docker to allow live reload
|
||||
- ./backend:/app
|
||||
- ./database:/database
|
||||
|
||||
########################################################
|
||||
# DATABASE ##############################################
|
||||
########################################################
|
||||
database:
|
||||
# we always run on production here since else the service lingers
|
||||
# feel free to change this behaviour if it seems useful
|
||||
# Due to problems with the volume caching the built files
|
||||
# we changed this to test build. This keeps the service running.
|
||||
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
|
||||
image: gradido/database:local-test_up
|
||||
build:
|
||||
target: test_up
|
||||
environment:
|
||||
- NODE_ENV="development"
|
||||
volumes:
|
||||
# This makes sure the docker container has its own node modules.
|
||||
# Therefore it is possible to have a different node version on the host machine
|
||||
- database_node_modules:/app/node_modules
|
||||
- database_build:/app/build
|
||||
# bind the local folder to the docker to allow live reload
|
||||
- ./database:/app
|
||||
|
||||
#########################################################
|
||||
## MARIADB ##############################################
|
||||
#########################################################
|
||||
mariadb:
|
||||
networks:
|
||||
- internal-net
|
||||
- external-net
|
||||
|
||||
#########################################################
|
||||
## NGINX ################################################
|
||||
#########################################################
|
||||
# nginx:
|
||||
|
||||
#########################################################
|
||||
## PHPMYADMIN ###########################################
|
||||
#########################################################
|
||||
phpmyadmin:
|
||||
image: phpmyadmin
|
||||
environment:
|
||||
- PMA_ARBITRARY=1
|
||||
#restart: always
|
||||
ports:
|
||||
- 8074:80
|
||||
networks:
|
||||
- internal-net
|
||||
- external-net
|
||||
volumes:
|
||||
- /sessions
|
||||
|
||||
volumes:
|
||||
frontend_node_modules:
|
||||
admin_node_modules:
|
||||
backend_node_modules:
|
||||
backend_database_node_modules:
|
||||
backend_database_build:
|
||||
database_node_modules:
|
||||
database_build:
|
||||
|
||||
@ -107,6 +107,9 @@ services:
|
||||
# Application only envs
|
||||
#env_file:
|
||||
# - ./frontend/.env
|
||||
volumes:
|
||||
# <host_machine_directy>:<container_directory> – mirror bidirectional path in local context with path in Docker container
|
||||
- ./logs/backend:/logs/backend
|
||||
|
||||
########################################################
|
||||
# DATABASE #############################################
|
||||
@ -149,6 +152,8 @@ services:
|
||||
- admin
|
||||
ports:
|
||||
- 80:80
|
||||
volumes:
|
||||
- ./logs/nginx:/var/log/nginx
|
||||
|
||||
networks:
|
||||
external-net:
|
||||
|
||||
114
frontend/src/components/Contributions/ContributionForm.spec.js
Normal file
114
frontend/src/components/Contributions/ContributionForm.spec.js
Normal file
@ -0,0 +1,114 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContributionForm from './ContributionForm.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('ContributionForm', () => {
|
||||
let wrapper
|
||||
|
||||
const propsData = {
|
||||
value: {
|
||||
id: null,
|
||||
date: '',
|
||||
memo: '',
|
||||
amount: '',
|
||||
},
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$store: {
|
||||
state: {
|
||||
creation: ['1000', '1000', '1000'],
|
||||
},
|
||||
},
|
||||
$i18n: {
|
||||
locale: 'en',
|
||||
},
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ContributionForm, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a DIV .contribution-form', () => {
|
||||
expect(wrapper.find('div.contribution-form').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('empty form data', () => {
|
||||
describe('buttons', () => {
|
||||
it('has reset enabled', () => {
|
||||
expect(wrapper.find('button[type="reset"]').attributes('disabled')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('has submit disabled', () => {
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('set contrubtion', () => {
|
||||
describe('fill in form data', () => {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({
|
||||
form: {
|
||||
id: null,
|
||||
date: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '200',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('buttons', () => {
|
||||
it('has reset enabled', () => {
|
||||
expect(wrapper.find('button[type="reset"]').attributes('disabled')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('has submit enabled', () => {
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update contrubtion', () => {
|
||||
describe('fill in form data and "id"', () => {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({
|
||||
form: {
|
||||
id: 2,
|
||||
date: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '200',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('buttons', () => {
|
||||
it('has reset enabled', () => {
|
||||
expect(wrapper.find('button[type="reset"]').attributes('disabled')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('has submit enabled', () => {
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
179
frontend/src/components/Contributions/ContributionForm.vue
Normal file
179
frontend/src/components/Contributions/ContributionForm.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="container contribution-form">
|
||||
<div class="my-3">
|
||||
<h3>{{ $t('contribution.formText.yourContribution') }}</h3>
|
||||
{{ $t('contribution.formText.bringYourTalentsTo') }}
|
||||
<ul class="my-3">
|
||||
<li v-html="lastMonthObject"></li>
|
||||
<li v-html="thisMonthObject"></li>
|
||||
</ul>
|
||||
|
||||
<div class="my-3">
|
||||
<b>{{ $t('contribution.formText.describeYourCommunity') }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<b-form ref="form" @submit.prevent="submit" class="border p-3">
|
||||
<label>{{ $t('contribution.selectDate') }}</label>
|
||||
<b-form-datepicker
|
||||
id="contribution-date"
|
||||
v-model="form.date"
|
||||
size="lg"
|
||||
:locale="$i18n.locale"
|
||||
:max="maximalDate"
|
||||
:min="minimalDate"
|
||||
class="mb-4"
|
||||
reset-value=""
|
||||
:label-no-date-selected="$t('contribution.noDateSelected')"
|
||||
required
|
||||
>
|
||||
<template #nav-prev-year><span></span></template>
|
||||
<template #nav-next-year><span></span></template>
|
||||
</b-form-datepicker>
|
||||
<validation-provider
|
||||
:rules="{
|
||||
required: true,
|
||||
min: minlength,
|
||||
max: maxlength,
|
||||
}"
|
||||
:name="$t('form.message')"
|
||||
v-slot="{ errors }"
|
||||
>
|
||||
<label class="mt-3">{{ $t('contribution.activity') }}</label>
|
||||
<b-form-textarea
|
||||
id="contribution-memo"
|
||||
v-model="form.memo"
|
||||
rows="3"
|
||||
max-rows="6"
|
||||
required
|
||||
></b-form-textarea>
|
||||
<b-col v-if="errors">
|
||||
<span v-for="error in errors" class="errors" :key="error">{{ error }}</span>
|
||||
</b-col>
|
||||
</validation-provider>
|
||||
<label class="mt-3">{{ $t('form.amount') }}</label>
|
||||
<b-input-group size="lg" prepend="GDD" append=".00">
|
||||
<b-form-input
|
||||
id="contribution-amount"
|
||||
v-model="form.amount"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="isThisMonth ? maxGddThisMonth : maxGddLastMonth"
|
||||
></b-form-input>
|
||||
</b-input-group>
|
||||
<div
|
||||
v-if="isThisMonth && parseInt(form.amount) > parseInt(maxGddThisMonth)"
|
||||
class="text-danger text-right"
|
||||
>
|
||||
{{ $t('contribution.formText.maxGDDforMonth', { amount: maxGddThisMonth }) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="!isThisMonth && parseInt(form.amount) > parseInt(maxGddLastMonth)"
|
||||
class="text-danger text-right"
|
||||
>
|
||||
{{ $t('contribution.formText.maxGDDforMonth', { amount: maxGddLastMonth }) }}
|
||||
</div>
|
||||
<b-row class="mt-3">
|
||||
<b-col>
|
||||
<b-button class="test-cancel" type="reset" variant="secondary" @click="reset">
|
||||
{{ $t('form.cancel') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-right">
|
||||
<b-button class="test-submit" type="submit" variant="primary" :disabled="disabled">
|
||||
{{ form.id ? $t('form.change') : $t('contribution.submit') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-form>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'ContributionForm',
|
||||
props: {
|
||||
value: { type: Object, required: true },
|
||||
updateAmount: { type: String, required: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
minlength: 5,
|
||||
maxlength: 255,
|
||||
maximalDate: new Date(),
|
||||
form: this.value, // includes 'id'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
if (this.form.id) {
|
||||
this.$emit('update-contribution', this.form)
|
||||
} else {
|
||||
this.$emit('set-contribution', this.form)
|
||||
}
|
||||
this.reset()
|
||||
},
|
||||
reset() {
|
||||
this.$refs.form.reset()
|
||||
this.form.id = null
|
||||
this.form.date = ''
|
||||
this.form.memo = ''
|
||||
this.form.amount = ''
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
/*
|
||||
* minimalDate() = Sets the date to the 1st of the previous month.
|
||||
*
|
||||
*/
|
||||
minimalDate() {
|
||||
return new Date(this.maximalDate.getFullYear(), this.maximalDate.getMonth() - 1, 1)
|
||||
},
|
||||
disabled() {
|
||||
if (
|
||||
this.form.date === '' ||
|
||||
this.form.memo.length < this.minlength ||
|
||||
this.form.amount <= 0 ||
|
||||
this.form.amount > 1000 ||
|
||||
(this.isThisMonth && parseInt(this.form.amount) > parseInt(this.maxGddThisMonth)) ||
|
||||
(!this.isThisMonth && parseInt(this.form.amount) > parseInt(this.maxGddLastMonth))
|
||||
)
|
||||
return true
|
||||
return false
|
||||
},
|
||||
lastMonthObject() {
|
||||
// new Date().getMonth === 1 If the current month is January, then one year must be gone back in the previous month
|
||||
const obj = {
|
||||
monthAndYear: this.$d(new Date(this.minimalDate), 'monthAndYear'),
|
||||
creation: this.maxGddLastMonth,
|
||||
}
|
||||
return this.$t('contribution.formText.openAmountForMonth', obj)
|
||||
},
|
||||
thisMonthObject() {
|
||||
const obj = {
|
||||
monthAndYear: this.$d(new Date(), 'monthAndYear'),
|
||||
creation: this.maxGddThisMonth,
|
||||
}
|
||||
return this.$t('contribution.formText.openAmountForMonth', obj)
|
||||
},
|
||||
isThisMonth() {
|
||||
return new Date(this.form.date).getMonth() === new Date().getMonth()
|
||||
},
|
||||
maxGddLastMonth() {
|
||||
// When edited, the amount is added back on top of the amount
|
||||
return this.form.id && !this.isThisMonth
|
||||
? parseInt(this.$store.state.creation[1]) + parseInt(this.updateAmount)
|
||||
: this.$store.state.creation[1]
|
||||
},
|
||||
maxGddThisMonth() {
|
||||
// When edited, the amount is added back on top of the amount
|
||||
return this.form.id && this.isThisMonth
|
||||
? parseInt(this.$store.state.creation[2]) + parseInt(this.updateAmount)
|
||||
: this.$store.state.creation[2]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
span.errors {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
120
frontend/src/components/Contributions/ContributionList.spec.js
Normal file
120
frontend/src/components/Contributions/ContributionList.spec.js
Normal file
@ -0,0 +1,120 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContributionList from './ContributionList.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('ContributionList', () => {
|
||||
let wrapper
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
contributionCount: 3,
|
||||
showPagination: true,
|
||||
pageSize: 25,
|
||||
items: [
|
||||
{
|
||||
id: 0,
|
||||
date: '07/06/2022',
|
||||
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
|
||||
amount: '200',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
date: '06/22/2022',
|
||||
memo: 'Ich habe 30 Stunden Frau Müller beim EInkaufen und im Haushalt geholfen.',
|
||||
amount: '600',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '05/04/2022',
|
||||
memo:
|
||||
'Ich habe 50 Stunden den Nachbarkindern bei ihren Hausaufgaben geholfen und Nachhilfeunterricht gegeben.',
|
||||
amount: '1000',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ContributionList, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a DIV .contribution-list', () => {
|
||||
expect(wrapper.find('div.contribution-list').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
describe('list count smaller than page size', () => {
|
||||
it('has no pagination buttons', () => {
|
||||
expect(wrapper.find('ul.pagination').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('list count greater than page size', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({ contributionCount: 33 })
|
||||
})
|
||||
|
||||
it('has pagination buttons', () => {
|
||||
expect(wrapper.find('ul.pagination').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('switch page', () => {
|
||||
const scrollToMock = jest.fn()
|
||||
window.scrollTo = scrollToMock
|
||||
|
||||
beforeEach(async () => {
|
||||
await wrapper.setProps({ contributionCount: 33 })
|
||||
wrapper.findComponent({ name: 'BPagination' }).vm.$emit('input', 2)
|
||||
})
|
||||
|
||||
it('emits update contribution list', () => {
|
||||
expect(wrapper.emitted('update-list-contributions')).toEqual([
|
||||
[{ currentPage: 2, pageSize: 25 }],
|
||||
])
|
||||
})
|
||||
|
||||
it('scrolls to top', () => {
|
||||
expect(scrollToMock).toBeCalledWith(0, 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update contribution', () => {
|
||||
beforeEach(() => {
|
||||
wrapper
|
||||
.findComponent({ name: 'ContributionListItem' })
|
||||
.vm.$emit('update-contribution-form', 'item')
|
||||
})
|
||||
|
||||
it('emits update contribution form', () => {
|
||||
expect(wrapper.emitted('update-contribution-form')).toEqual([['item']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete contribution', () => {
|
||||
beforeEach(() => {
|
||||
wrapper
|
||||
.findComponent({ name: 'ContributionListItem' })
|
||||
.vm.$emit('delete-contribution', { id: 2 })
|
||||
})
|
||||
|
||||
it('emits delete contribution', () => {
|
||||
expect(wrapper.emitted('delete-contribution')).toEqual([[{ id: 2 }]])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
76
frontend/src/components/Contributions/ContributionList.vue
Normal file
76
frontend/src/components/Contributions/ContributionList.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="contribution-list container">
|
||||
<div class="list-group" v-for="item in items" :key="item.id">
|
||||
<contribution-list-item
|
||||
v-bind="item"
|
||||
@update-contribution-form="updateContributionForm"
|
||||
@delete-contribution="deleteContribution"
|
||||
/>
|
||||
</div>
|
||||
<b-pagination
|
||||
v-if="isPaginationVisible"
|
||||
class="mt-3"
|
||||
pills
|
||||
size="lg"
|
||||
v-model="currentPage"
|
||||
:per-page="pageSize"
|
||||
:total-rows="contributionCount"
|
||||
align="center"
|
||||
></b-pagination>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import ContributionListItem from '@/components/Contributions/ContributionListItem.vue'
|
||||
|
||||
export default {
|
||||
name: 'ContributionList',
|
||||
components: {
|
||||
ContributionListItem,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
contributionCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
showPagination: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
pageSize: { type: Number, default: 25 },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentPage: 1,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateListContributions() {
|
||||
this.$emit('update-list-contributions', {
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
window.scrollTo(0, 0)
|
||||
},
|
||||
updateContributionForm(item) {
|
||||
this.$emit('update-contribution-form', item)
|
||||
},
|
||||
deleteContribution(item) {
|
||||
this.$emit('delete-contribution', item)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isPaginationVisible() {
|
||||
return this.showPagination && this.pageSize < this.contributionCount
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentPage() {
|
||||
this.updateListContributions()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,145 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContributionListItem from './ContributionListItem.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('ContributionListItem', () => {
|
||||
let wrapper
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
id: 1,
|
||||
createdAt: '26/07/2022',
|
||||
contributionDate: '07/06/2022',
|
||||
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
|
||||
amount: '200',
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(ContributionListItem, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a DIV .contribution-list-item', () => {
|
||||
expect(wrapper.find('div.contribution-list-item').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('contribution type', () => {
|
||||
it('is pending by default', () => {
|
||||
expect(wrapper.vm.type).toBe('pending')
|
||||
})
|
||||
|
||||
it('is deleted when deletedAt is present', async () => {
|
||||
await wrapper.setProps({ deletedAt: new Date().toISOString() })
|
||||
expect(wrapper.vm.type).toBe('deleted')
|
||||
})
|
||||
|
||||
it('is confirmed when confirmedAt is present', async () => {
|
||||
await wrapper.setProps({ confirmedAt: new Date().toISOString() })
|
||||
expect(wrapper.vm.type).toBe('confirmed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contribution icon', () => {
|
||||
it('is bell-fill by default', () => {
|
||||
expect(wrapper.vm.icon).toBe('bell-fill')
|
||||
})
|
||||
|
||||
it('is x-circle when deletedAt is present', async () => {
|
||||
await wrapper.setProps({ deletedAt: new Date().toISOString() })
|
||||
expect(wrapper.vm.icon).toBe('x-circle')
|
||||
})
|
||||
|
||||
it('is check when confirmedAt is present', async () => {
|
||||
await wrapper.setProps({ confirmedAt: new Date().toISOString() })
|
||||
expect(wrapper.vm.icon).toBe('check')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contribution variant', () => {
|
||||
it('is primary by default', () => {
|
||||
expect(wrapper.vm.variant).toBe('primary')
|
||||
})
|
||||
|
||||
it('is danger when deletedAt is present', async () => {
|
||||
await wrapper.setProps({ deletedAt: new Date().toISOString() })
|
||||
expect(wrapper.vm.variant).toBe('danger')
|
||||
})
|
||||
|
||||
it('is success at when confirmedAt is present', async () => {
|
||||
await wrapper.setProps({ confirmedAt: new Date().toISOString() })
|
||||
expect(wrapper.vm.variant).toBe('success')
|
||||
})
|
||||
})
|
||||
|
||||
describe('date', () => {
|
||||
it('is equal to createdAt', () => {
|
||||
expect(wrapper.vm.date).toBe(wrapper.vm.createdAt)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete contribution', () => {
|
||||
let spy
|
||||
|
||||
describe('edit contribution', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.findAll('div.pointer').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('emits update contribution form', () => {
|
||||
expect(wrapper.emitted('update-contribution-form')).toEqual([
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
contributionDate: '07/06/2022',
|
||||
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
|
||||
amount: '200',
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm deletion', () => {
|
||||
beforeEach(() => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(true))
|
||||
wrapper.findAll('div.pointer').at(1).trigger('click')
|
||||
})
|
||||
|
||||
it('opens the modal', () => {
|
||||
expect(spy).toBeCalledWith('contribution.delete')
|
||||
})
|
||||
|
||||
it('emits delete contribution', () => {
|
||||
expect(wrapper.emitted('delete-contribution')).toEqual([[{ id: 1 }]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel deletion', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(false))
|
||||
await wrapper.findAll('div.pointer').at(1).trigger('click')
|
||||
})
|
||||
|
||||
it('does not emit delete contribution', () => {
|
||||
expect(wrapper.emitted('delete-contribution')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
114
frontend/src/components/Contributions/ContributionListItem.vue
Normal file
114
frontend/src/components/Contributions/ContributionListItem.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="contribution-list-item">
|
||||
<slot>
|
||||
<div class="border p-3 w-100 mb-1" :class="`border-${variant}`">
|
||||
<div class="d-inline-flex">
|
||||
<div class="mr-2"><b-icon :icon="icon" :variant="variant" class="h2"></b-icon></div>
|
||||
<div v-if="firstName" class="mr-3">{{ firstName }} {{ lastName }}</div>
|
||||
<div class="mr-2" :class="type != 'deleted' ? 'font-weight-bold' : ''">
|
||||
{{ amount | GDD }}
|
||||
</div>
|
||||
{{ $t('math.minus') }}
|
||||
<div class="mx-2">{{ $d(new Date(date), 'short') }}</div>
|
||||
</div>
|
||||
<div class="mr-2">
|
||||
<span>{{ $t('contribution.date') }}</span>
|
||||
<span>
|
||||
{{ $d(new Date(contributionDate), 'monthAndYear') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mr-2">{{ memo }}</div>
|
||||
<div v-if="type === 'pending' && !firstName" class="d-flex flex-row-reverse">
|
||||
<div
|
||||
class="pointer ml-5"
|
||||
@click="
|
||||
$emit('update-contribution-form', {
|
||||
id: id,
|
||||
contributionDate: contributionDate,
|
||||
memo: memo,
|
||||
amount: amount,
|
||||
})
|
||||
"
|
||||
>
|
||||
<b-icon icon="pencil" class="h2"></b-icon>
|
||||
</div>
|
||||
<div class="pointer" @click="deleteContribution({ id })">
|
||||
<b-icon icon="trash" class="h2"></b-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'ContributionListItem',
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
},
|
||||
amount: {
|
||||
type: String,
|
||||
},
|
||||
memo: {
|
||||
type: String,
|
||||
},
|
||||
firstName: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
lastName: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: String,
|
||||
},
|
||||
contributionDate: {
|
||||
type: String,
|
||||
},
|
||||
deletedAt: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
confirmedBy: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
confirmedAt: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
type() {
|
||||
if (this.deletedAt) return 'deleted'
|
||||
if (this.confirmedAt) return 'confirmed'
|
||||
return 'pending'
|
||||
},
|
||||
icon() {
|
||||
if (this.deletedAt) return 'x-circle'
|
||||
if (this.confirmedAt) return 'check'
|
||||
return 'bell-fill'
|
||||
},
|
||||
variant() {
|
||||
if (this.deletedAt) return 'danger'
|
||||
if (this.confirmedAt) return 'success'
|
||||
return 'primary'
|
||||
},
|
||||
date() {
|
||||
// if (this.deletedAt) return this.deletedAt
|
||||
// if (this.confirmedAt) return this.confirmedAt
|
||||
// return this.contributionDate
|
||||
return this.createdAt
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
deleteContribution(item) {
|
||||
this.$bvModal.msgBoxConfirm(this.$t('contribution.delete')).then(async (value) => {
|
||||
if (value) this.$emit('delete-contribution', item)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -33,7 +33,7 @@ describe('Sidebar', () => {
|
||||
|
||||
describe('navigation Navbar', () => {
|
||||
it('has seven b-nav-item in the navbar', () => {
|
||||
expect(wrapper.findAll('.nav-item')).toHaveLength(7)
|
||||
expect(wrapper.findAll('.nav-item')).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('has first nav-item "navigation.overview" in navbar', () => {
|
||||
@ -47,18 +47,26 @@ describe('Sidebar', () => {
|
||||
it('has first nav-item "navigation.transactions" in navbar', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(2).text()).toEqual('navigation.transactions')
|
||||
})
|
||||
|
||||
it('has first nav-item "navigation.community" in navbar', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(3).text()).toContain('navigation.community')
|
||||
})
|
||||
|
||||
it('has first nav-item "navigation.profile" in navbar', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(3).text()).toEqual('navigation.profile')
|
||||
expect(wrapper.findAll('.nav-item').at(4).text()).toEqual('navigation.profile')
|
||||
})
|
||||
|
||||
it('has a link to the members area', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(4).text()).toContain('navigation.members_area')
|
||||
expect(wrapper.findAll('.nav-item').at(4).find('a').attributes('href')).toBe('#')
|
||||
expect(wrapper.findAll('.nav-item').at(5).text()).toEqual('navigation.members_area')
|
||||
expect(wrapper.findAll('.nav-item').at(5).find('a').attributes('href')).toBe('#')
|
||||
})
|
||||
|
||||
it('has first nav-item "navigation.admin_area" in navbar', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(5).text()).toEqual('navigation.admin_area')
|
||||
expect(wrapper.findAll('.nav-item').at(6).text()).toEqual('navigation.admin_area')
|
||||
})
|
||||
|
||||
it('has first nav-item "navigation.logout" in navbar', () => {
|
||||
expect(wrapper.findAll('.nav-item').at(6).text()).toEqual('navigation.logout')
|
||||
expect(wrapper.findAll('.nav-item').at(7).text()).toEqual('navigation.logout')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -16,6 +16,10 @@
|
||||
<b-icon icon="layout-text-sidebar-reverse" aria-hidden="true"></b-icon>
|
||||
{{ $t('navigation.transactions') }}
|
||||
</b-nav-item>
|
||||
<b-nav-item to="/community" class="mb-3">
|
||||
<b-icon icon="people" aria-hidden="true"></b-icon>
|
||||
{{ $t('navigation.community') }}
|
||||
</b-nav-item>
|
||||
<b-nav-item to="/profile" class="mb-3">
|
||||
<b-icon icon="gear" aria-hidden="true"></b-icon>
|
||||
{{ $t('navigation.profile') }}
|
||||
|
||||
@ -89,3 +89,33 @@ export const redeemTransactionLink = gql`
|
||||
redeemTransactionLink(code: $code)
|
||||
}
|
||||
`
|
||||
|
||||
export const createContribution = gql`
|
||||
mutation($creationDate: String!, $memo: String!, $amount: Decimal!) {
|
||||
createContribution(creationDate: $creationDate, memo: $memo, amount: $amount) {
|
||||
amount
|
||||
memo
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const updateContribution = gql`
|
||||
mutation($contributionId: Int!, $amount: Decimal!, $memo: String!, $creationDate: String!) {
|
||||
updateContribution(
|
||||
contributionId: $contributionId
|
||||
amount: $amount
|
||||
memo: $memo
|
||||
creationDate: $creationDate
|
||||
) {
|
||||
id
|
||||
amount
|
||||
memo
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const deleteContribution = gql`
|
||||
mutation($id: Int!) {
|
||||
deleteContribution(id: $id)
|
||||
}
|
||||
`
|
||||
|
||||
@ -162,3 +162,50 @@ export const listTransactionLinks = gql`
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const listContributions = gql`
|
||||
query(
|
||||
$currentPage: Int = 1
|
||||
$pageSize: Int = 25
|
||||
$order: Order = DESC
|
||||
$filterConfirmed: Boolean = false
|
||||
) {
|
||||
listContributions(
|
||||
currentPage: $currentPage
|
||||
pageSize: $pageSize
|
||||
order: $order
|
||||
filterConfirmed: $filterConfirmed
|
||||
) {
|
||||
contributionCount
|
||||
contributionList {
|
||||
id
|
||||
amount
|
||||
memo
|
||||
createdAt
|
||||
contributionDate
|
||||
confirmedAt
|
||||
confirmedBy
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const listAllContributions = gql`
|
||||
query($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {
|
||||
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
|
||||
contributionCount
|
||||
contributionList {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
amount
|
||||
memo
|
||||
createdAt
|
||||
contributionDate
|
||||
confirmedAt
|
||||
confirmedBy
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@ -75,6 +75,19 @@ const dateTimeFormats = {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
},
|
||||
monthShort: {
|
||||
month: 'short',
|
||||
},
|
||||
month: {
|
||||
month: 'long',
|
||||
},
|
||||
year: {
|
||||
year: 'numeric',
|
||||
},
|
||||
monthAndYear: {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
},
|
||||
},
|
||||
de: {
|
||||
short: {
|
||||
@ -90,6 +103,19 @@ const dateTimeFormats = {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
},
|
||||
monthShort: {
|
||||
month: 'short',
|
||||
},
|
||||
month: {
|
||||
month: 'long',
|
||||
},
|
||||
year: {
|
||||
year: 'numeric',
|
||||
},
|
||||
monthAndYear: {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -26,9 +26,37 @@
|
||||
"community": "Gemeinschaft",
|
||||
"continue-to-registration": "Weiter zur Registrierung",
|
||||
"current-community": "Aktuelle Gemeinschaft",
|
||||
"myContributions": "Meine Beiträge zum Gemeinwohl",
|
||||
"other-communities": "Weitere Gemeinschaften",
|
||||
"submitContribution": "Beitrag einreichen",
|
||||
"switch-to-this-community": "zu dieser Gemeinschaft wechseln"
|
||||
},
|
||||
"contribution": {
|
||||
"activity": "Tätigkeit",
|
||||
"alert": {
|
||||
"communityNoteList": "Hier findest du alle eingereichten und bestätigten Beiträge von allen Mitgliedern aus dieser Gemeinschaft.",
|
||||
"confirm": "bestätigt",
|
||||
"myContributionNoteList": "Eingereichte Beiträge, die noch nicht bestätigt wurden, kannst du jederzeit bearbeiten oder löschen.",
|
||||
"myContributionNoteSupport": "Es wird bald an dieser Stelle die Möglichkeit geben das ein Dialog zwischen Moderatoren und dir stattfinden kann. Solltest du jetzt Probleme haben bitte nimm Kontakt mit dem Support auf.",
|
||||
"pending": "Eingereicht und wartet auf Bestätigung",
|
||||
"rejected": "abgelehnt"
|
||||
},
|
||||
"date": "Beitrag für:",
|
||||
"delete": "Beitrag löschen! Bist du sicher?",
|
||||
"deleted": "Der Beitrag wurde gelöscht! Wird aber sichtbar bleiben.",
|
||||
"formText": {
|
||||
"bringYourTalentsTo": "Bring dich mit deinen Talenten in die Gemeinschaft ein! Dein freiwilliges Engagement honorieren wir mit 20 GDD pro Stunde bis maximal 1.000 GDD im Monat.",
|
||||
"describeYourCommunity": "Beschreibe deine Gemeinwohl-Tätigkeit mit Angabe der Stunden und trage einen Betrag von 20 GDD pro Stunde ein! Nach Bestätigung durch einen Moderator wird der Betrag deinem Konto gutgeschrieben.",
|
||||
"maxGDDforMonth": "Du kannst für den ausgewählten Monat nur noch maximal {amount} GDD einreichen.",
|
||||
"openAmountForMonth": "Für <b>{monthAndYear}</b> kannst du noch <b>{creation}</b> GDD einreichen.",
|
||||
"yourContribution": "Dein Beitrag zum Gemeinwohl"
|
||||
},
|
||||
"noDateSelected": "Wähle irgendein Datum im Monat",
|
||||
"selectDate": "Wann war dein Beitrag?",
|
||||
"submit": "Einreichen",
|
||||
"submitted": "Der Beitrag wurde eingereicht.",
|
||||
"updated": "Der Beitrag wurde geändert."
|
||||
},
|
||||
"contribution-link": {
|
||||
"thanksYouWith": "dankt dir mit"
|
||||
},
|
||||
@ -78,12 +106,12 @@
|
||||
"amount": "Betrag",
|
||||
"at": "am",
|
||||
"cancel": "Abbrechen",
|
||||
"change": "Ändern",
|
||||
"check_now": "Jetzt prüfen",
|
||||
"close": "Schließen",
|
||||
"current_balance": "Aktueller Kontostand",
|
||||
"date": "Datum",
|
||||
"description": "Beschreibung",
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"firstname": "Vorname",
|
||||
"from": "Von",
|
||||
@ -193,6 +221,7 @@
|
||||
},
|
||||
"navigation": {
|
||||
"admin_area": "Adminbereich",
|
||||
"community": "Gemeinschaft",
|
||||
"logout": "Abmelden",
|
||||
"members_area": "Mitgliederbereich",
|
||||
"overview": "Übersicht",
|
||||
@ -271,6 +300,7 @@
|
||||
"days": "Tage",
|
||||
"hours": "Stunden",
|
||||
"minutes": "Minuten",
|
||||
"month": "Monat",
|
||||
"months": "Monate",
|
||||
"seconds": "Sekunden",
|
||||
"years": "Jahr"
|
||||
|
||||
@ -26,9 +26,37 @@
|
||||
"community": "Community",
|
||||
"continue-to-registration": "Continue to registration",
|
||||
"current-community": "Current community",
|
||||
"myContributions": "My contributions to the common good",
|
||||
"other-communities": "Other communities",
|
||||
"submitContribution": "Submit contribution",
|
||||
"switch-to-this-community": "Switch to this community"
|
||||
},
|
||||
"contribution": {
|
||||
"activity": "Activity",
|
||||
"alert": {
|
||||
"communityNoteList": "Here you will find all submitted and confirmed contributions from all members of this community.",
|
||||
"confirm": "confirmed",
|
||||
"myContributionNoteList": "You can edit or delete entries that have not yet been confirmed at any time.",
|
||||
"myContributionNoteSupport": "Soon there will be the possibility for a dialogue between moderators and you. If you have any problems now, please contact the support.",
|
||||
"pending": "Submitted and waiting for confirmation",
|
||||
"rejected": "deleted"
|
||||
},
|
||||
"date": "Contribution for:",
|
||||
"delete": "Delete Contribution! Are you sure?",
|
||||
"deleted": "The contribution has been deleted! But it will remain visible.",
|
||||
"formText": {
|
||||
"bringYourTalentsTo": "Bring your talents to the community! Your voluntary commitment will be rewarded with 20 GDD per hour up to a maximum of 1,000 GDD per month.",
|
||||
"describeYourCommunity": "Describe your community service activity with hours and enter an amount of 20 GDD per hour! After confirmation by a moderator, the amount will be credited to your account.",
|
||||
"maxGDDforMonth": "You can only submit a maximum of {amount} GDD for the selected month.",
|
||||
"openAmountForMonth": "For <b>{monthAndYear}</b>, you can still submit <b>{creation}</b> GDD.",
|
||||
"yourContribution": "Your contribution to the common good"
|
||||
},
|
||||
"noDateSelected": "Choose any date in the month",
|
||||
"selectDate": "When was your contribution?",
|
||||
"submit": "Submit",
|
||||
"submitted": "The contribution was submitted.",
|
||||
"updated": "The contribution was changed."
|
||||
},
|
||||
"contribution-link": {
|
||||
"thanksYouWith": "thanks you with"
|
||||
},
|
||||
@ -78,12 +106,12 @@
|
||||
"amount": "Amount",
|
||||
"at": "at",
|
||||
"cancel": "Cancel",
|
||||
"change": "Change",
|
||||
"check_now": "Check now",
|
||||
"close": "Close",
|
||||
"current_balance": "Current Balance",
|
||||
"date": "Date",
|
||||
"description": "Description",
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"firstname": "Firstname",
|
||||
"from": "from",
|
||||
@ -193,6 +221,7 @@
|
||||
},
|
||||
"navigation": {
|
||||
"admin_area": "Admin Area",
|
||||
"community": "Community",
|
||||
"logout": "Logout",
|
||||
"members_area": "Members area",
|
||||
"overview": "Overview",
|
||||
@ -271,6 +300,7 @@
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes",
|
||||
"month": "Month",
|
||||
"months": "Months",
|
||||
"seconds": "Seconds",
|
||||
"years": "Year"
|
||||
|
||||
408
frontend/src/pages/Community.spec.js
Normal file
408
frontend/src/pages/Community.spec.js
Normal file
@ -0,0 +1,408 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Community from './Community'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '@test/testSetup'
|
||||
import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations'
|
||||
import { listContributions, listAllContributions, verifyLogin } from '@/graphql/queries'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const mockStoreDispach = jest.fn()
|
||||
const apolloQueryMock = jest.fn()
|
||||
const apolloMutationMock = jest.fn()
|
||||
|
||||
describe('Community', () => {
|
||||
let wrapper
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$apollo: {
|
||||
query: apolloQueryMock,
|
||||
mutate: apolloMutationMock,
|
||||
},
|
||||
$store: {
|
||||
dispatch: mockStoreDispach,
|
||||
state: {
|
||||
creation: ['1000', '1000', '1000'],
|
||||
},
|
||||
},
|
||||
$i18n: {
|
||||
locale: 'en',
|
||||
},
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(Community, {
|
||||
localVue,
|
||||
mocks,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
apolloQueryMock.mockResolvedValue({
|
||||
data: {
|
||||
listContributions: {
|
||||
contributionList: [
|
||||
{
|
||||
id: 1555,
|
||||
amount: '200',
|
||||
memo: 'Fleisig, fleisig am Arbeiten mein lieber Freund, 50 Zeichen sind viel',
|
||||
createdAt: '2022-07-15T08:47:06.000Z',
|
||||
deletedAt: null,
|
||||
confirmedBy: null,
|
||||
confirmedAt: null,
|
||||
},
|
||||
],
|
||||
contributionCount: 1,
|
||||
},
|
||||
listAllContributions: {
|
||||
contributionList: [
|
||||
{
|
||||
id: 1555,
|
||||
amount: '200',
|
||||
memo: 'Fleisig, fleisig am Arbeiten mein lieber Freund, 50 Zeichen sind viel',
|
||||
createdAt: '2022-07-15T08:47:06.000Z',
|
||||
deletedAt: null,
|
||||
confirmedBy: null,
|
||||
confirmedAt: null,
|
||||
},
|
||||
{
|
||||
id: 1556,
|
||||
amount: '400',
|
||||
memo: 'Ein anderer lieber Freund ist auch sehr felißig am Arbeiten!!!!',
|
||||
createdAt: '2022-07-16T08:47:06.000Z',
|
||||
deletedAt: null,
|
||||
confirmedBy: null,
|
||||
confirmedAt: null,
|
||||
},
|
||||
],
|
||||
contributionCount: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a DIV .community-page', () => {
|
||||
expect(wrapper.find('div.community-page').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('tabs', () => {
|
||||
it('has three tabs', () => {
|
||||
expect(wrapper.findAll('div[role="tabpanel"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('has first tab active by default', () => {
|
||||
expect(wrapper.findAll('div[role="tabpanel"]').at(0).classes('active')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API calls after creation', () => {
|
||||
it('emits update transactions', () => {
|
||||
expect(wrapper.emitted('update-transactions')).toEqual([[0]])
|
||||
})
|
||||
|
||||
it('queries list of own contributions', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith({
|
||||
fetchPolicy: 'no-cache',
|
||||
query: listContributions,
|
||||
variables: {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('queries list of all contributions', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith({
|
||||
fetchPolicy: 'no-cache',
|
||||
query: listAllContributions,
|
||||
variables: {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('server response is error', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
apolloQueryMock.mockRejectedValue({ message: 'Ups' })
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('toasts two errors', () => {
|
||||
expect(toastErrorSpy).toBeCalledTimes(2)
|
||||
expect(toastErrorSpy).toBeCalledWith('Ups')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('set contrubtion', () => {
|
||||
describe('with success', () => {
|
||||
const now = new Date().toISOString()
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
apolloMutationMock.mockResolvedValue({
|
||||
data: {
|
||||
createContribution: true,
|
||||
},
|
||||
})
|
||||
await wrapper.setData({
|
||||
form: {
|
||||
id: null,
|
||||
date: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '200',
|
||||
},
|
||||
})
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls the create contribution mutation', () => {
|
||||
expect(apolloMutationMock).toBeCalledWith({
|
||||
fetchPolicy: 'no-cache',
|
||||
mutation: createContribution,
|
||||
variables: {
|
||||
creationDate: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '200',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('contribution.submitted')
|
||||
})
|
||||
|
||||
it('updates the contribution list', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith({
|
||||
fetchPolicy: 'no-cache',
|
||||
query: listContributions,
|
||||
variables: {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies the login (to get the new creations available)', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith({
|
||||
query: verifyLogin,
|
||||
fetchPolicy: 'network-only',
|
||||
})
|
||||
})
|
||||
|
||||
it('set all data to the default values)', () => {
|
||||
expect(wrapper.vm.form.id).toBe(null)
|
||||
expect(wrapper.vm.form.date).toBe('')
|
||||
expect(wrapper.vm.form.memo).toBe('')
|
||||
expect(wrapper.vm.form.amount).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with error', () => {
|
||||
const now = new Date().toISOString()
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
apolloMutationMock.mockRejectedValue({
|
||||
message: 'Ouch!',
|
||||
})
|
||||
await wrapper.setData({
|
||||
form: {
|
||||
id: null,
|
||||
date: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '200',
|
||||
},
|
||||
})
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('toasts the error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouch!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update contrubtion', () => {
|
||||
describe('with success', () => {
|
||||
const now = new Date().toISOString()
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
apolloMutationMock.mockResolvedValue({
|
||||
data: {
|
||||
updateContribution: true,
|
||||
},
|
||||
})
|
||||
await wrapper
|
||||
.findComponent({ name: 'ContributionForm' })
|
||||
.vm.$emit('update-contribution', {
|
||||
id: 2,
|
||||
date: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '400',
|
||||
})
|
||||
})
|
||||
|
||||
it('calls the update contribution mutation', () => {
|
||||
expect(apolloMutationMock).toBeCalledWith({
|
||||
fetchPolicy: 'no-cache',
|
||||
mutation: updateContribution,
|
||||
variables: {
|
||||
contributionId: 2,
|
||||
creationDate: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '400',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('contribution.updated')
|
||||
})
|
||||
|
||||
it('updates the contribution list', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith({
|
||||
fetchPolicy: 'no-cache',
|
||||
query: listContributions,
|
||||
variables: {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies the login (to get the new creations available)', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith({
|
||||
query: verifyLogin,
|
||||
fetchPolicy: 'network-only',
|
||||
})
|
||||
})
|
||||
|
||||
it('set all data to the default values)', () => {
|
||||
expect(wrapper.vm.form.id).toBe(null)
|
||||
expect(wrapper.vm.form.date).toBe('')
|
||||
expect(wrapper.vm.form.memo).toBe('')
|
||||
expect(wrapper.vm.form.amount).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with error', () => {
|
||||
const now = new Date().toISOString()
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
apolloMutationMock.mockRejectedValue({
|
||||
message: 'Oh No!',
|
||||
})
|
||||
await wrapper
|
||||
.findComponent({ name: 'ContributionForm' })
|
||||
.vm.$emit('update-contribution', {
|
||||
id: 2,
|
||||
date: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '400',
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts the error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh No!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete contribution', () => {
|
||||
let contributionListComponent
|
||||
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({ tabIndex: 1 })
|
||||
contributionListComponent = await wrapper.findComponent({ name: 'ContributionList' })
|
||||
})
|
||||
|
||||
describe('with success', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
apolloMutationMock.mockResolvedValue({
|
||||
data: {
|
||||
deleteContribution: true,
|
||||
},
|
||||
})
|
||||
contributionListComponent.vm.$emit('delete-contribution', { id: 2 })
|
||||
})
|
||||
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutationMock).toBeCalledWith({
|
||||
fetchPolicy: 'no-cache',
|
||||
mutation: deleteContribution,
|
||||
variables: {
|
||||
id: 2,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('contribution.deleted')
|
||||
})
|
||||
|
||||
it('updates the contribution list', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith({
|
||||
fetchPolicy: 'no-cache',
|
||||
query: listContributions,
|
||||
variables: {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies the login (to get the new creations available)', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith({
|
||||
query: verifyLogin,
|
||||
fetchPolicy: 'network-only',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with error', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
apolloMutationMock.mockRejectedValue({
|
||||
message: 'Oh my god!',
|
||||
})
|
||||
contributionListComponent.vm.$emit('delete-contribution', { id: 2 })
|
||||
})
|
||||
|
||||
it('toasts the error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh my god!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update contribution form', () => {
|
||||
const now = new Date().toISOString()
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({ tabIndex: 1 })
|
||||
await wrapper
|
||||
.findComponent({ name: 'ContributionList' })
|
||||
.vm.$emit('update-contribution-form', {
|
||||
id: 2,
|
||||
contributionDate: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '400',
|
||||
})
|
||||
})
|
||||
|
||||
it('sets the form data to the new values', () => {
|
||||
expect(wrapper.vm.form.id).toBe(2)
|
||||
expect(wrapper.vm.form.date).toBe(now)
|
||||
expect(wrapper.vm.form.memo).toBe('Mein Beitrag zur Gemeinschaft für diesen Monat ...')
|
||||
expect(wrapper.vm.form.amount).toBe('400')
|
||||
})
|
||||
|
||||
it('sets tab index back to 0', () => {
|
||||
expect(wrapper.vm.tabIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
276
frontend/src/pages/Community.vue
Normal file
276
frontend/src/pages/Community.vue
Normal file
@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="community-page">
|
||||
<div>
|
||||
<b-tabs v-model="tabIndex" content-class="mt-3" align="center">
|
||||
<b-tab :title="$t('community.submitContribution')" active>
|
||||
<contribution-form
|
||||
@set-contribution="setContribution"
|
||||
@update-contribution="updateContribution"
|
||||
v-model="form"
|
||||
:updateAmount="updateAmount"
|
||||
/>
|
||||
</b-tab>
|
||||
<b-tab :title="$t('community.myContributions')">
|
||||
<div>
|
||||
<b-alert show dismissible fade variant="secondary" class="text-dark">
|
||||
<h4 class="alert-heading">{{ $t('community.myContributions') }}</h4>
|
||||
<p>
|
||||
{{ $t('contribution.alert.myContributionNoteList') }}
|
||||
</p>
|
||||
<ul class="h2">
|
||||
<li>
|
||||
<b-icon icon="bell-fill" variant="primary"></b-icon>
|
||||
{{ $t('contribution.alert.pending') }}
|
||||
</li>
|
||||
<li>
|
||||
<b-icon icon="check" variant="success"></b-icon>
|
||||
{{ $t('contribution.alert.confirm') }}
|
||||
</li>
|
||||
<li>
|
||||
<b-icon icon="x-circle" variant="danger"></b-icon>
|
||||
{{ $t('contribution.alert.rejected') }}
|
||||
</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<p class="mb-0">
|
||||
{{ $t('contribution.alert.myContributionNoteSupport') }}
|
||||
</p>
|
||||
</b-alert>
|
||||
</div>
|
||||
<contribution-list
|
||||
:items="items"
|
||||
@update-list-contributions="updateListContributions"
|
||||
@update-contribution-form="updateContributionForm"
|
||||
@delete-contribution="deleteContribution"
|
||||
:contributionCount="contributionCount"
|
||||
:showPagination="true"
|
||||
:pageSize="pageSize"
|
||||
/>
|
||||
</b-tab>
|
||||
<b-tab :title="$t('navigation.community')">
|
||||
<b-alert show dismissible fade variant="secondary" class="text-dark">
|
||||
<h4 class="alert-heading">{{ $t('navigation.community') }}</h4>
|
||||
<p>
|
||||
{{ $t('contribution.alert.communityNoteList') }}
|
||||
</p>
|
||||
<ul class="h2">
|
||||
<li>
|
||||
<b-icon icon="bell-fill" variant="primary"></b-icon>
|
||||
{{ $t('contribution.alert.pending') }}
|
||||
</li>
|
||||
<li>
|
||||
<b-icon icon="check" variant="success"></b-icon>
|
||||
{{ $t('contribution.alert.confirm') }}
|
||||
</li>
|
||||
</ul>
|
||||
</b-alert>
|
||||
<contribution-list
|
||||
:items="itemsAll"
|
||||
@update-list-contributions="updateListAllContributions"
|
||||
@update-contribution-form="updateContributionForm"
|
||||
:contributionCount="contributionCountAll"
|
||||
:showPagination="true"
|
||||
:pageSize="pageSizeAll"
|
||||
/>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import ContributionForm from '@/components/Contributions/ContributionForm.vue'
|
||||
import ContributionList from '@/components/Contributions/ContributionList.vue'
|
||||
import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations'
|
||||
import { listContributions, listAllContributions, verifyLogin } from '@/graphql/queries'
|
||||
|
||||
export default {
|
||||
name: 'Community',
|
||||
components: {
|
||||
ContributionForm,
|
||||
ContributionList,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tabIndex: 0,
|
||||
items: [],
|
||||
itemsAll: [],
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
pageSizeAll: 25,
|
||||
contributionCount: 0,
|
||||
contributionCountAll: 0,
|
||||
form: {
|
||||
id: null,
|
||||
date: '',
|
||||
memo: '',
|
||||
amount: '',
|
||||
},
|
||||
updateAmount: '',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setContribution(data) {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
fetchPolicy: 'no-cache',
|
||||
mutation: createContribution,
|
||||
variables: {
|
||||
creationDate: data.date,
|
||||
memo: data.memo,
|
||||
amount: data.amount,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.toastSuccess(this.$t('contribution.submitted'))
|
||||
this.updateListContributions({
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
this.updateListAllContributions({
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
this.verifyLogin()
|
||||
})
|
||||
.catch((err) => {
|
||||
this.toastError(err.message)
|
||||
})
|
||||
},
|
||||
updateContribution(data) {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
fetchPolicy: 'no-cache',
|
||||
mutation: updateContribution,
|
||||
variables: {
|
||||
contributionId: data.id,
|
||||
creationDate: data.date,
|
||||
memo: data.memo,
|
||||
amount: data.amount,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.toastSuccess(this.$t('contribution.updated'))
|
||||
this.updateListContributions({
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
this.updateListAllContributions({
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
this.verifyLogin()
|
||||
})
|
||||
.catch((err) => {
|
||||
this.toastError(err.message)
|
||||
})
|
||||
},
|
||||
deleteContribution(data) {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
fetchPolicy: 'no-cache',
|
||||
mutation: deleteContribution,
|
||||
variables: {
|
||||
id: data.id,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.toastSuccess(this.$t('contribution.deleted'))
|
||||
this.updateListContributions({
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
this.updateListAllContributions({
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
this.verifyLogin()
|
||||
})
|
||||
.catch((err) => {
|
||||
this.toastError(err.message)
|
||||
})
|
||||
},
|
||||
updateListAllContributions(pagination) {
|
||||
this.$apollo
|
||||
.query({
|
||||
fetchPolicy: 'no-cache',
|
||||
query: listAllContributions,
|
||||
variables: {
|
||||
currentPage: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
const {
|
||||
data: { listAllContributions },
|
||||
} = result
|
||||
this.contributionCountAll = listAllContributions.contributionCount
|
||||
this.itemsAll = listAllContributions.contributionList
|
||||
})
|
||||
.catch((err) => {
|
||||
this.toastError(err.message)
|
||||
})
|
||||
},
|
||||
updateListContributions(pagination) {
|
||||
this.$apollo
|
||||
.query({
|
||||
fetchPolicy: 'no-cache',
|
||||
query: listContributions,
|
||||
variables: {
|
||||
currentPage: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
const {
|
||||
data: { listContributions },
|
||||
} = result
|
||||
this.contributionCount = listContributions.contributionCount
|
||||
this.items = listContributions.contributionList
|
||||
})
|
||||
.catch((err) => {
|
||||
this.toastError(err.message)
|
||||
})
|
||||
},
|
||||
verifyLogin() {
|
||||
this.$apollo
|
||||
.query({
|
||||
query: verifyLogin,
|
||||
fetchPolicy: 'network-only',
|
||||
})
|
||||
.then((result) => {
|
||||
const {
|
||||
data: { verifyLogin },
|
||||
} = result
|
||||
this.$store.dispatch('login', verifyLogin)
|
||||
})
|
||||
.catch(() => {
|
||||
this.$emit('logout')
|
||||
})
|
||||
},
|
||||
updateContributionForm(item) {
|
||||
this.form.id = item.id
|
||||
this.form.date = item.contributionDate
|
||||
this.form.memo = item.memo
|
||||
this.form.amount = item.amount
|
||||
this.updateAmount = item.amount
|
||||
this.tabIndex = 0
|
||||
},
|
||||
updateTransactions(pagination) {
|
||||
this.$emit('update-transactions', pagination)
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// verifyLogin is important at this point so that creation is updated on reload if they are deleted in a session in the admin area.
|
||||
this.verifyLogin()
|
||||
this.updateListContributions({
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
this.updateListAllContributions({
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
})
|
||||
this.updateTransactions(0)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -109,7 +109,7 @@ export default {
|
||||
return this.$route.params.code.search(/^CL-/) === 0
|
||||
},
|
||||
itemType() {
|
||||
// link wurde gelöscht: am, von
|
||||
// link is deleted: at, from
|
||||
if (this.linkData.deletedAt) {
|
||||
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
||||
this.redeemedBoxText = this.$t('gdd_per_link.link-deleted', {
|
||||
|
||||
@ -50,7 +50,7 @@ describe('router', () => {
|
||||
})
|
||||
|
||||
it('has sixteen routes defined', () => {
|
||||
expect(routes).toHaveLength(16)
|
||||
expect(routes).toHaveLength(17)
|
||||
})
|
||||
|
||||
describe('overview', () => {
|
||||
@ -75,6 +75,17 @@ describe('router', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('community', () => {
|
||||
it('requires authorization', () => {
|
||||
expect(routes.find((r) => r.path === '/community').meta.requiresAuth).toBeTruthy()
|
||||
})
|
||||
|
||||
it('loads the "Community" page', async () => {
|
||||
const component = await routes.find((r) => r.path === '/community').component()
|
||||
expect(component.default.name).toBe('Community')
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile', () => {
|
||||
it('requires authorization', () => {
|
||||
expect(routes.find((r) => r.path === '/profile').meta.requiresAuth).toBeTruthy()
|
||||
|
||||
@ -38,6 +38,13 @@ const routes = [
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/community',
|
||||
component: () => import('@/pages/Community.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login/:code?',
|
||||
component: () => import('@/pages/Login.vue'),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user