Merge pull request #2042 from gradido/2038-Community-contribution-site-and-form

feat: 🍰 Community Contribution Site And Form
This commit is contained in:
Wolfgang Huß 2022-07-25 13:34:41 +02:00 committed by GitHub
commit d06d7c30cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1546 additions and 8 deletions

View File

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

View File

@ -0,0 +1,168 @@
<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"
:max="maximalDate"
:min="minimalDate"
class="mb-4"
reset-value=""
:label-no-date-selected="$t('contribution.noDateSelected')"
required
></b-form-datepicker>
<label class="mt-3">{{ $t('contribution.activity') }}</label>
<b-form-textarea
id="contribution-memo"
v-model="form.memo"
rows="3"
max-rows="6"
required
:minlength="minlength"
:maxlength="maxlength"
></b-form-textarea>
<div
v-show="form.memo.length > 0"
class="text-right"
:class="form.memo.length < minlength ? 'text-danger' : 'text-success'"
>
{{ form.memo.length }}
<span v-if="form.memo.length < minlength">{{ $t('math.lower') }} {{ minlength }}</span>
<span v-else>{{ $t('math.divide') }} {{ maxlength }}</span>
</div>
<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 type="button" variant="light" @click.prevent="reset">
{{ $t('form.reset') }}
</b-button>
</b-col>
<b-col class="text-right">
<b-button class="test-submit" type="submit" variant="primary" :disabled="disabled">
{{ value.id ? $t('form.edit') : $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: 50,
maxlength: 255,
maximalDate: new Date(),
form: this.value,
id: this.value.id,
}
},
methods: {
submit() {
if (this.value.id) {
this.$emit('update-contribution', this.form)
} else {
this.$emit('set-contribution', this.form)
}
this.reset()
},
reset() {
this.$refs.form.reset()
this.form.date = ''
this.id = null
this.form.memo = ''
},
},
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.value.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.value.id && this.isThisMonth
? parseInt(this.$store.state.creation[2]) + parseInt(this.updateAmount)
: this.$store.state.creation[2]
},
},
}
</script>

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

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

View File

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

View File

@ -0,0 +1,107 @@
<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">{{ 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
},
},
methods: {
deleteContribution(item) {
this.$bvModal.msgBoxConfirm(this.$t('contribution.delete')).then(async (value) => {
if (value) this.$emit('delete-contribution', item)
})
},
},
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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