diff --git a/DOCKER_MORE_CLOSELY.md b/DOCKER_MORE_CLOSELY.md new file mode 100644 index 000000000..f2aae81c7 --- /dev/null +++ b/DOCKER_MORE_CLOSELY.md @@ -0,0 +1,44 @@ +# Docker More Closely + +## Apple M1 Platform + +***Attention:** For using Docker commands in Apple M1 environments!* + +### Enviroment Variable For Apple M1 Platform + +To set the Docker platform environment variable in your terminal tab, run: + +```bash +# set env variable for your shell +$ export DOCKER_DEFAULT_PLATFORM=linux/amd64 +``` + +### Docker Compose Override File For Apple M1 Platform + +For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform: + +```bash +# in main folder + +# for development +$ docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up + +# for production +$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up +``` + +## Analysing Docker Builds + +To analyze a Docker build, there is a wonderful tool called [dive](https://github.com/wagoodman/dive). Please sponsor if you're using it! + +The `dive build` command is exactly the right one to fulfill what we are looking for. +We can use it just like the `docker build` command and get an analysis afterwards. + +So, in our main folder, we use it in the following way: + +```bash +# in main folder +$ dive build --target -t "gradido/:local-" / +``` + +For the specific applications, see our [publish.yml](.github/workflows/publish.yml). diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 12cad529c..84ae09cf8 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -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}`) } diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index b584624c2..20f11ff9a 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -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({ diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index ef4467a71..a22715fb4 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -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 { + 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 { + 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({ diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 023e5b2ff..bc062a1f4 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -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, diff --git a/backend/src/graphql/resolver/const/const.ts b/backend/src/graphql/resolver/const/const.ts index d5ba08784..e4eb9a13b 100644 --- a/backend/src/graphql/resolver/const/const.ts +++ b/backend/src/graphql/resolver/const/const.ts @@ -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 diff --git a/docker-compose.apple-m1.override.yml b/docker-compose.apple-m1.override.yml new file mode 100644 index 000000000..72152f9ae --- /dev/null +++ b/docker-compose.apple-m1.override.yml @@ -0,0 +1,43 @@ +# This file defines the Apple M1 chip settings. It overrides docker-compose.override.yml, +# which defines the development settings. +# To use it it is required to explicitly define if you want to build with it: +# > docker-compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up + +version: "3.4" + +services: + ######################################################## + # FRONTEND ############################################# + ######################################################## + frontend: + platform: linux/amd64 + + ######################################################## + # ADMIN INTERFACE ###################################### + ######################################################## + admin: + platform: linux/amd64 + + ######################################################### + ## MARIADB ############################################## + ######################################################### + mariadb: + platform: linux/amd64 + + ######################################################## + # BACKEND ############################################## + ######################################################## + backend: + platform: linux/amd64 + + ######################################################## + # DATABASE ############################################# + ######################################################## + database: + platform: linux/amd64 + + ######################################################### + ## NGINX ################################################ + ######################################################### + nginx: + platform: linux/amd64 diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 3d63da9e3..2af3c41ee 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,11 +1,13 @@ version: "3.4" services: + ######################################################## # FRONTEND ############################################# ######################################################## frontend: - image: gradido/frontend:development + # 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: @@ -22,7 +24,8 @@ services: # ADMIN INTERFACE ###################################### ######################################################## admin: - image: gradido/admin:development + # 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: @@ -39,7 +42,8 @@ services: # BACKEND ############################################## ######################################################## backend: - image: gradido/backend:development + # 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: @@ -62,10 +66,11 @@ services: ######################################################## 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. - image: gradido/database:test_up + # 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: @@ -89,9 +94,7 @@ services: ######################################################### ## NGINX ################################################ ######################################################### - nginx: - volumes: - - ./logs/nginx:/var/log/nginx + # nginx: ######################################################### ## PHPMYADMIN ########################################### diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 221ecba20..7db318176 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -6,6 +6,7 @@ services: # 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:test build: target: test diff --git a/docker-compose.yml b/docker-compose.yml index 213b200cd..5f0ab4dde 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,11 +6,13 @@ version: "3.4" services: + ######################################################## # FRONTEND ############################################# ######################################################## frontend: - image: gradido/frontend:latest + # 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-production build: context: ./frontend target: production @@ -35,7 +37,8 @@ services: # ADMIN INTERFACE ###################################### ######################################################## admin: - image: gradido/admin:latest + # 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-production build: context: ./admin target: production @@ -77,7 +80,8 @@ services: # BACKEND ############################################## ######################################################## backend: - image: gradido/backend:latest + # 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-production build: # since we have to include the entities from ./database we cannot define the context as ./backend # this might blow build image size to the moon ?! @@ -103,12 +107,16 @@ services: # Application only envs #env_file: # - ./frontend/.env + volumes: + # : – mirror bidirectional path in local context with path in Docker container + - ./logs/backend:/logs/backend ######################################################## # DATABASE ############################################# ######################################################## database: - #image: gradido/database:production_up + # 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-production_up build: context: ./database target: production_up @@ -144,6 +152,8 @@ services: - admin ports: - 80:80 + volumes: + - ./logs/nginx:/var/log/nginx networks: external-net: diff --git a/frontend/src/components/Contributions/ContributionForm.spec.js b/frontend/src/components/Contributions/ContributionForm.spec.js new file mode 100644 index 000000000..5b05957bb --- /dev/null +++ b/frontend/src/components/Contributions/ContributionForm.spec.js @@ -0,0 +1,49 @@ +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'], + }, + }, + } + + 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) + }) + + it('is submit button disable of true', () => { + expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled') + }) + }) +}) diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue new file mode 100644 index 000000000..6b8ef39d0 --- /dev/null +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -0,0 +1,168 @@ + + diff --git a/frontend/src/components/Contributions/ContributionList.spec.js b/frontend/src/components/Contributions/ContributionList.spec.js new file mode 100644 index 000000000..a1dfc934d --- /dev/null +++ b/frontend/src/components/Contributions/ContributionList.spec.js @@ -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 }]]) + }) + }) + }) +}) diff --git a/frontend/src/components/Contributions/ContributionList.vue b/frontend/src/components/Contributions/ContributionList.vue new file mode 100644 index 000000000..097452194 --- /dev/null +++ b/frontend/src/components/Contributions/ContributionList.vue @@ -0,0 +1,76 @@ + + diff --git a/frontend/src/components/Contributions/ContributionListItem.spec.js b/frontend/src/components/Contributions/ContributionListItem.spec.js new file mode 100644 index 000000000..20f4db959 --- /dev/null +++ b/frontend/src/components/Contributions/ContributionListItem.spec.js @@ -0,0 +1,156 @@ +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, + 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('contribution date', () => { + it('is contributionDate by default', () => { + expect(wrapper.vm.date).toBe(wrapper.vm.contributionDate) + }) + + it('is deletedAt when deletedAt is present', async () => { + const now = new Date().toISOString() + await wrapper.setProps({ deletedAt: now }) + expect(wrapper.vm.date).toBe(now) + }) + + it('is confirmedAt at when confirmedAt is present', async () => { + const now = new Date().toISOString() + await wrapper.setProps({ confirmedAt: now }) + expect(wrapper.vm.date).toBe(now) + }) + }) + + 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() + }) + }) + }) + }) +}) diff --git a/frontend/src/components/Contributions/ContributionListItem.vue b/frontend/src/components/Contributions/ContributionListItem.vue new file mode 100644 index 000000000..ca766a008 --- /dev/null +++ b/frontend/src/components/Contributions/ContributionListItem.vue @@ -0,0 +1,107 @@ + + diff --git a/frontend/src/components/Menu/Sidebar.spec.js b/frontend/src/components/Menu/Sidebar.spec.js index fec7945c8..f6051c733 100644 --- a/frontend/src/components/Menu/Sidebar.spec.js +++ b/frontend/src/components/Menu/Sidebar.spec.js @@ -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') }) }) }) diff --git a/frontend/src/components/Menu/Sidebar.vue b/frontend/src/components/Menu/Sidebar.vue index 028b7aca6..b54eb541e 100644 --- a/frontend/src/components/Menu/Sidebar.vue +++ b/frontend/src/components/Menu/Sidebar.vue @@ -16,6 +16,10 @@ {{ $t('navigation.transactions') }} + + + {{ $t('navigation.community') }} + {{ $t('navigation.profile') }} diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index 9b035cba6..ec1f5a410 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -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) + } +` diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 27e63d568..b74770227 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -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 + } + } + } +` diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index f4f969008..3136c6d80 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -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', + }, }, } diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 378dcc919..ef81d463e 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -26,9 +26,36 @@ "community": "Gemeinschaft", "continue-to-registration": "Weiter zur Registrierung", "current-community": "Aktuelle Gemeinschaft", + "myContributions": "Meine Beiträge", "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": "Hier findest du chronologisch aufgelistet alle deine eingereichten Beiträge. Es gibt drei Darstellungsarten. Du kannst deine Beiträge, welche noch nicht bestätigt wurden, jederzeit bearbeiten.", + "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" + }, + "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 {monthAndYear} kannst du noch {creation} 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" }, @@ -176,8 +203,10 @@ "login": "Anmeldung", "math": { "aprox": "~", + "divide": "/", "equal": "=", "exclaim": "!", + "lower": "<", "minus": "−", "pipe": "|" }, @@ -193,6 +222,7 @@ }, "navigation": { "admin_area": "Adminbereich", + "community": "Gemeinschaft", "logout": "Abmelden", "members_area": "Mitgliederbereich", "overview": "Übersicht", @@ -271,6 +301,7 @@ "days": "Tage", "hours": "Stunden", "minutes": "Minuten", + "month": "Monat", "months": "Monate", "seconds": "Sekunden", "years": "Jahr" diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 0bfde65ed..47753487d 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -26,9 +26,36 @@ "community": "Community", "continue-to-registration": "Continue to registration", "current-community": "Current community", + "myContributions": "My contributions", "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": "Here you will find a chronological list of all your submitted contributions. There are three display types. There are three ways of displaying your posts. You can edit your contributions, which 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" + }, + "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 {monthAndYear}, you can still submit {creation} 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" }, @@ -176,8 +203,10 @@ "login": "Login", "math": { "aprox": "~", + "divide": "/", "equal": "=", "exclaim": "!", + "lower": "<", "minus": "−", "pipe": "|" }, @@ -193,6 +222,7 @@ }, "navigation": { "admin_area": "Admin Area", + "community": "Community", "logout": "Logout", "members_area": "Members area", "overview": "Overview", @@ -271,6 +301,7 @@ "days": "Days", "hours": "Hours", "minutes": "Minutes", + "month": "Month", "months": "Months", "seconds": "Seconds", "years": "Year" diff --git a/frontend/src/pages/Community.spec.js b/frontend/src/pages/Community.spec.js new file mode 100644 index 000000000..d834ddac1 --- /dev/null +++ b/frontend/src/pages/Community.spec.js @@ -0,0 +1,391 @@ +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'], + }, + }, + } + + 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', + }) + }) + }) + + 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', + }) + }) + }) + + 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 date 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) + }) + }) + }) +}) diff --git a/frontend/src/pages/Community.vue b/frontend/src/pages/Community.vue new file mode 100644 index 000000000..64aca6156 --- /dev/null +++ b/frontend/src/pages/Community.vue @@ -0,0 +1,276 @@ + + diff --git a/frontend/src/pages/TransactionLink.vue b/frontend/src/pages/TransactionLink.vue index 699c350ae..57236b55c 100644 --- a/frontend/src/pages/TransactionLink.vue +++ b/frontend/src/pages/TransactionLink.vue @@ -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', { diff --git a/frontend/src/routes/router.test.js b/frontend/src/routes/router.test.js index 32ab90d4e..2eefaeb36 100644 --- a/frontend/src/routes/router.test.js +++ b/frontend/src/routes/router.test.js @@ -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() diff --git a/frontend/src/routes/routes.js b/frontend/src/routes/routes.js index e68f97502..540ef9d69 100755 --- a/frontend/src/routes/routes.js +++ b/frontend/src/routes/routes.js @@ -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'),