Merge pull request #2194 from Human-Connection/donation-info

Add donation status and button
This commit is contained in:
mattwr18 2019-11-12 21:46:49 +01:00 committed by GitHub
commit 25adced793
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 710 additions and 3 deletions

View File

@ -135,6 +135,7 @@ const permissions = shield(
blockedUsers: isAuthenticated,
notifications: isAuthenticated,
profilePagePosts: or(onlyEnabledContent, isModerator),
Donations: isAuthenticated,
},
Mutation: {
'*': deny,
@ -177,6 +178,7 @@ const permissions = shield(
VerifyEmailAddress: isAuthenticated,
pinPost: isAdmin,
unpinPost: isAdmin,
UpdateDonations: isAdmin,
},
User: {
email: or(isMyOwn, isAdmin),

View File

@ -0,0 +1,14 @@
import uuid from 'uuid/v4'
module.exports = {
id: { type: 'string', primary: true, default: uuid },
goal: { type: 'number' },
progress: { type: 'number' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: {
type: 'string',
isoDate: true,
required: true,
default: () => new Date().toISOString(),
},
}

View File

@ -12,4 +12,5 @@ export default {
Category: require('./Category.js'),
Tag: require('./Tag.js'),
Location: require('./Location.js'),
Donations: require('./Donations.js'),
}

View File

@ -24,6 +24,7 @@ export default applyScalars(
'SocialMedia',
'NOTIFIED',
'REPORTED',
'Donations',
],
// add 'User' here as soon as possible
},
@ -44,6 +45,7 @@ export default applyScalars(
'EMOTED',
'NOTIFIED',
'REPORTED',
'Donations',
],
// add 'User' here as soon as possible
},

View File

@ -0,0 +1,32 @@
export default {
Mutation: {
UpdateDonations: async (_parent, params, context, _resolveInfo) => {
const { driver } = context
const session = driver.session()
let donations
const writeTxResultPromise = session.writeTransaction(async txc => {
const updateDonationsTransactionResponse = await txc.run(
`
MATCH (donations:Donations)
WITH donations LIMIT 1
SET donations += $params
SET donations.updatedAt = toString(datetime())
RETURN donations
`,
{ params },
)
return updateDonationsTransactionResponse.records.map(
record => record.get('donations').properties,
)
})
try {
const txResult = await writeTxResultPromise
if (!txResult[0]) return null
donations = txResult[0]
} finally {
session.close()
}
return donations
},
},
}

View File

@ -0,0 +1,174 @@
import { createTestClient } from 'apollo-server-testing'
import Factory from '../../seed/factories'
import { gql } from '../../jest/helpers'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
let mutate, query, authenticatedUser, variables
const factory = Factory()
const instance = getNeode()
const driver = getDriver()
const updateDonationsMutation = gql`
mutation($goal: Int, $progress: Int) {
UpdateDonations(goal: $goal, progress: $progress) {
id
goal
progress
createdAt
updatedAt
}
}
`
const donationsQuery = gql`
query {
Donations {
id
goal
progress
}
}
`
describe('donations', () => {
let currentUser, newlyCreatedDonations
beforeAll(async () => {
await factory.cleanDatabase()
authenticatedUser = undefined
const { server } = createServer({
context: () => {
return {
driver,
neode: instance,
user: authenticatedUser,
}
},
})
mutate = createTestClient(server).mutate
query = createTestClient(server).query
})
beforeEach(async () => {
variables = {}
newlyCreatedDonations = await factory.create('Donations')
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('query for donations', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = undefined
await expect(query({ query: donationsQuery, variables })).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
})
})
describe('authenticated', () => {
beforeEach(async () => {
currentUser = await factory.create('User', {
id: 'normal-user',
role: 'user',
})
authenticatedUser = await currentUser.toJson()
})
it('returns the current Donations info', async () => {
await expect(query({ query: donationsQuery, variables })).resolves.toMatchObject({
data: { Donations: [{ goal: 15000, progress: 0 }] },
})
})
})
})
})
describe('update donations', () => {
beforeEach(() => {
variables = { goal: 20000, progress: 3000 }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = undefined
await expect(
mutate({ mutation: updateDonationsMutation, variables }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
})
})
describe('authenticated', () => {
describe('as a normal user', () => {
beforeEach(async () => {
currentUser = await factory.create('User', {
id: 'normal-user',
role: 'user',
})
authenticatedUser = await currentUser.toJson()
})
it('throws authorization error', async () => {
await expect(
mutate({ mutation: updateDonationsMutation, variables }),
).resolves.toMatchObject({
data: { UpdateDonations: null },
errors: [{ message: 'Not Authorised!' }],
})
})
})
describe('as a moderator', () => {
beforeEach(async () => {
currentUser = await factory.create('User', {
id: 'moderator',
role: 'moderator',
})
authenticatedUser = await currentUser.toJson()
})
it('throws authorization error', async () => {
await expect(
mutate({ mutation: updateDonationsMutation, variables }),
).resolves.toMatchObject({
data: { UpdateDonations: null },
errors: [{ message: 'Not Authorised!' }],
})
})
})
describe('as an admin', () => {
beforeEach(async () => {
currentUser = await factory.create('User', {
id: 'admin',
role: 'admin',
})
authenticatedUser = await currentUser.toJson()
})
it('updates Donations info', async () => {
await expect(
mutate({ mutation: updateDonationsMutation, variables }),
).resolves.toMatchObject({
data: { UpdateDonations: { goal: 20000, progress: 3000 } },
errors: undefined,
})
})
it('updates the updatedAt attribute', async () => {
newlyCreatedDonations = await newlyCreatedDonations.toJson()
const {
data: { UpdateDonations },
} = await mutate({ mutation: updateDonationsMutation, variables })
expect(newlyCreatedDonations.updatedAt).toBeTruthy()
expect(Date.parse(newlyCreatedDonations.updatedAt)).toEqual(expect.any(Number))
expect(UpdateDonations.updatedAt).toBeTruthy()
expect(Date.parse(UpdateDonations.updatedAt)).toEqual(expect.any(Number))
expect(newlyCreatedDonations.updatedAt).not.toEqual(UpdateDonations.updatedAt)
})
})
})
})
})
})

View File

@ -0,0 +1,15 @@
type Donations {
id: ID!
goal: Int!
progress: Int!
createdAt: String
updatedAt: String
}
type Query {
Donations: [Donations]
}
type Mutation {
UpdateDonations(goal: Int, progress: Int): Donations
}

View File

@ -0,0 +1,18 @@
import uuid from 'uuid/v4'
export default function create() {
return {
factory: async ({ args, neodeInstance }) => {
const defaults = {
id: uuid(),
goal: 15000,
progress: 0,
}
args = {
...defaults,
...args,
}
return neodeInstance.create('Donations', args)
},
}
}

View File

@ -8,6 +8,7 @@ import createTag from './tags.js'
import createSocialMedia from './socialMedia.js'
import createLocation from './locations.js'
import createEmailAddress from './emailAddresses.js'
import createDonations from './donations.js'
import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js'
const factories = {
@ -21,6 +22,7 @@ const factories = {
Location: createLocation,
EmailAddress: createEmailAddress,
UnverifiedEmailAddress: createUnverifiedEmailAddresss,
Donations: createDonations,
}
export const cleanDatabase = async (options = {}) => {

View File

@ -929,6 +929,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}),
)
await factory.create('Donations')
/* eslint-disable-next-line no-console */
console.log('Seeded Data...')
process.exit(0)

View File

@ -0,0 +1,80 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import DonationInfo from './DonationInfo.vue'
const localVue = createLocalVue()
localVue.use(Styleguide)
const mockDate = new Date(2019, 11, 6)
global.Date = jest.fn(() => mockDate)
describe('DonationInfo.vue', () => {
let mocks, wrapper
beforeEach(() => {
mocks = {
$t: jest.fn(string => string),
$i18n: {
locale: () => 'de',
},
}
})
const Wrapper = () => mount(DonationInfo, { mocks, localVue })
it('includes a link to the Human Connection donations website', () => {
expect(
Wrapper()
.find('a')
.attributes('href'),
).toBe('https://human-connection.org/spenden/')
})
it('displays a call to action button', () => {
expect(
Wrapper()
.find('.ds-button')
.text(),
).toBe('donations.donate-now')
})
it('creates a title from the current month and a translation string', () => {
mocks.$t = jest.fn(() => 'Spenden für')
expect(Wrapper().vm.title).toBe('Spenden für Dezember')
})
describe('mount with data', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.setData({ goal: 50000, progress: 10000 })
})
describe('given german locale', () => {
it('creates a label from the given amounts and a translation string', () => {
expect(mocks.$t).toBeCalledWith(
'donations.amount-of-total',
expect.objectContaining({
amount: '10.000',
total: '50.000',
}),
)
})
})
describe('given english locale', () => {
beforeEach(() => {
mocks.$i18n.locale = () => 'en'
})
it('creates a label from the given amounts and a translation string', () => {
expect(mocks.$t).toBeCalledWith(
'donations.amount-of-total',
expect.objectContaining({
amount: '10,000',
total: '50,000',
}),
)
})
})
})
})

View File

@ -0,0 +1,66 @@
<template>
<div class="donation-info">
<progress-bar :title="title" :label="label" :goal="goal" :progress="progress" />
<a target="_blank" href="https://human-connection.org/spenden/">
<ds-button primary>{{ $t('donations.donate-now') }}</ds-button>
</a>
</div>
</template>
<script>
import { DonationsQuery } from '~/graphql/Donations'
import ProgressBar from '~/components/ProgressBar/ProgressBar.vue'
export default {
components: {
ProgressBar,
},
data() {
return {
goal: 15000,
progress: 0,
}
},
computed: {
title() {
const today = new Date()
const month = today.toLocaleString(this.$i18n.locale(), { month: 'long' })
return `${this.$t('donations.donations-for')} ${month}`
},
label() {
return this.$t('donations.amount-of-total', {
amount: this.progress.toLocaleString(this.$i18n.locale()),
total: this.goal.toLocaleString(this.$i18n.locale()),
})
},
},
apollo: {
Donations: {
query() {
return DonationsQuery()
},
update({ Donations }) {
if (!Donations[0]) return
const { goal, progress } = Donations[0]
this.goal = goal
this.progress = progress
},
},
},
}
</script>
<style lang="scss">
.donation-info {
display: flex;
align-items: flex-end;
height: 100%;
@media (max-width: 546px) {
width: 100%;
height: 50%;
justify-content: flex-end;
margin-bottom: $space-x-small;
}
}
</style>

View File

@ -0,0 +1,65 @@
import { mount } from '@vue/test-utils'
import ProgressBar from './ProgressBar'
describe('ProgessBar.vue', () => {
let propsData
beforeEach(() => {
propsData = {
goal: 50000,
progress: 10000,
}
})
const Wrapper = () => mount(ProgressBar, { propsData })
describe('given only goal and progress', () => {
it('renders no title', () => {
expect(
Wrapper()
.find('.progress-bar__title')
.exists(),
).toBe(false)
})
it('renders no label', () => {
expect(
Wrapper()
.find('.progress-bar__label')
.exists(),
).toBe(false)
})
it('calculates the progress bar width as a percentage of the goal', () => {
expect(Wrapper().vm.progressBarWidth).toBe('width: 20%;')
})
})
describe('given a title', () => {
beforeEach(() => {
propsData.title = 'This is progress'
})
it('renders the title', () => {
expect(
Wrapper()
.find('.progress-bar__title')
.text(),
).toBe('This is progress')
})
})
describe('given a label', () => {
beforeEach(() => {
propsData.label = 'Going well'
})
it('renders the label', () => {
expect(
Wrapper()
.find('.progress-bar__label')
.text(),
).toBe('Going well')
})
})
})

View File

@ -0,0 +1,97 @@
<template>
<div class="progress-bar">
<div class="progress-bar__goal"></div>
<div class="progress-bar__progress" :style="progressBarWidth"></div>
<h4 v-if="title" class="progress-bar__title">{{ title }}</h4>
<span v-if="label" class="progress-bar__label">{{ label }}</span>
</div>
</template>
<script>
export default {
props: {
goal: {
type: Number,
required: true,
},
label: {
type: String,
},
progress: {
type: Number,
required: true,
},
title: {
type: String,
},
},
computed: {
progressBarWidth() {
return `width: ${(this.progress / this.goal) * 100}%;`
},
},
}
</script>
<style lang="scss">
.progress-bar {
position: relative;
height: 100%;
width: 240px;
margin-right: $space-x-small;
@media (max-width: 680px) {
width: 180px;
}
@media (max-width: 546px) {
flex-basis: 50%;
flex-grow: 1;
}
}
.progress-bar__title {
position: absolute;
top: -2px;
left: $space-xx-small;
margin: 0;
@media (max-width: 546px) {
top: $space-xx-small;
}
@media (max-width: 350px) {
font-size: $font-size-small;
}
}
.progress-bar__goal {
position: absolute;
bottom: 0;
left: 0;
height: 37.5px; // styleguide-button-size
width: 100%;
background-color: $color-neutral-100;
border-radius: $border-radius-base;
}
.progress-bar__progress {
position: absolute;
bottom: 1px;
left: 0;
height: 35.5px; // styleguide-button-size - 2px border
max-width: 100%;
background-color: $color-yellow;
border-radius: $border-radius-base;
}
.progress-bar__label {
position: absolute;
top: 50%;
left: $space-xx-small;
@media (max-width: 350px) {
font-size: $font-size-small;
}
}
</style>

View File

@ -0,0 +1,24 @@
import gql from 'graphql-tag'
export const DonationsQuery = () => gql`
query {
Donations {
id
goal
progress
}
}
`
export const UpdateDonations = () => {
return gql`
mutation($goal: Int, $progress: Int) {
UpdateDonations(goal: $goal, progress: $progress) {
id
goal
progress
updatedAt
}
}
`
}

View File

@ -62,6 +62,11 @@
}
}
},
"donations": {
"donations-for": "Spenden für",
"donate-now": "Jetzt spenden",
"amount-of-total": "{amount} von {total} € erreicht"
},
"maintenance": {
"title": "Human Connection befindet sich in der Wartung",
"explanation": "Zurzeit führen wir einige geplante Wartungsarbeiten durch, bitte versuch es später erneut.",
@ -375,6 +380,12 @@
"name": "Benutzer einladen",
"title": "Leute einladen",
"description": "Einladungen sind ein wunderbarer Weg, deine Freund in deinem Netzwerk zu haben …"
},
"donations": {
"name": "Spendeninfo",
"goal": "Monatlich benötigte Spenden",
"progress": "Bereits gesammelte Spenden",
"successfulUpdate": "Spenden-Info erfolgreich aktualisiert!"
}
},
"post": {

View File

@ -63,6 +63,11 @@
}
}
},
"donations": {
"donations-for": "Donations for",
"donate-now": "Donate now",
"amount-of-total": "{amount} of {total} € collected"
},
"maintenance": {
"title": "Human Connection is under maintenance",
"explanation": "At the moment we are doing some scheduled maintenance, please try again later.",
@ -376,6 +381,12 @@
"name": "Invite users",
"title": "Invite people",
"description": "Invitations are a wonderful way to have your friends in your network …"
},
"donations": {
"name": "Donations info",
"goal": "Monthly donations needed",
"progress": "Donations collected so far",
"successfulUpdate": "Donations info updated successfully!"
}
},
"post": {

View File

@ -380,6 +380,12 @@
"name": "Convidar usuários",
"title": "Convidar pessoas",
"description": "Convites são uma maneira maravilhosa de ter seus amigos em sua rede …"
},
"donations": {
"name": "Informações sobre Doações",
"goal": "Doações mensais necessárias",
"progress": "Doações arrecadadas até o momento",
"successfulUpdate": "Informações sobre doações atualizadas com sucesso!"
}
},
"post": {

View File

@ -55,6 +55,10 @@ export default {
name: this.$t('admin.invites.name'),
path: `/admin/invite`,
},
{
name: this.$t('admin.donations.name'),
path: '/admin/donations',
},
// TODO implement
/* {
name: this.$t('admin.settings.name'),

View File

@ -0,0 +1,63 @@
<template>
<ds-card :header="$t('admin.donations.name')">
<ds-form v-model="formData" @submit="submit">
<ds-input model="goal" :label="$t('admin.donations.goal')" placeholder="15000" icon="money" />
<ds-input
model="progress"
:label="$t('admin.donations.progress')"
placeholder="1200"
icon="money"
/>
<ds-button primary type="submit" :disabled="!formData.goal || !formData.progress">
{{ $t('actions.save') }}
</ds-button>
</ds-form>
</ds-card>
</template>
<script>
import { DonationsQuery, UpdateDonations } from '~/graphql/Donations'
export default {
data() {
return {
formData: {
goal: null,
progress: null,
},
}
},
methods: {
submit() {
const { goal, progress } = this.formData
this.$apollo
.mutate({
mutation: UpdateDonations(),
variables: {
goal: parseInt(goal),
progress: parseInt(progress),
},
})
.then(() => {
this.$toast.success(this.$t('admin.donations.successfulUpdate'))
})
.catch(error => this.$toast.error(error.message))
},
},
apollo: {
Donations: {
query() {
return DonationsQuery()
},
update({ Donations }) {
if (!Donations[0]) return
const { goal, progress } = Donations[0]
this.formData = {
goal,
progress,
}
},
},
},
}
</script>

View File

@ -64,6 +64,9 @@ describe('PostIndex', () => {
truncate: a => a,
removeLinks: jest.fn(),
},
$i18n: {
locale: () => 'de',
},
// If you are mocking router, than don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$router: {
history: {

View File

@ -4,7 +4,8 @@
<ds-grid-item v-show="hashtag" :row-span="2" column-span="fullWidth">
<filter-menu :hashtag="hashtag" @clearSearch="clearSearch" />
</ds-grid-item>
<ds-grid-item :row-span="2" column-span="fullWidth">
<ds-grid-item :row-span="2" column-span="fullWidth" class="top-info-bar">
<donation-info />
<div class="sorting-dropdown">
<ds-select
v-model="selected"
@ -57,6 +58,7 @@
</template>
<script>
import DonationInfo from '~/components/DonationInfo/DonationInfo.vue'
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
import HcEmpty from '~/components/Empty/Empty'
import HcPostCard from '~/components/PostCard/PostCard.vue'
@ -69,6 +71,7 @@ import PostMutations from '~/graphql/PostMutations'
export default {
components: {
DonationInfo,
FilterMenu,
HcPostCard,
HcLoadMore,
@ -262,7 +265,20 @@ export default {
.sorting-dropdown {
width: 250px;
position: relative;
float: right;
margin: 4px 0;
@media (max-width: 680px) {
width: 180px;
}
}
.top-info-bar {
display: flex;
justify-content: space-between;
align-items: flex-end;
@media (max-width: 546px) {
grid-row-end: span 3 !important;
flex-direction: column;
}
}
</style>