diff --git a/frontend/model/contracts/shared/time.js b/frontend/model/contracts/shared/time.js
index 9dbce282ff..ec80075498 100644
--- a/frontend/model/contracts/shared/time.js
+++ b/frontend/model/contracts/shared/time.js
@@ -224,8 +224,8 @@ export function timeSince (datems: number, dateNow: number = Date.now()): string
if (interval >= DAYS_MILLIS * 2) {
// Make sure to replace any ordinary space character by a non-breaking one.
- // TODO: use .replaceAll when migrating to TS.
- return humanDate(datems).replace(/\x20/g, '\xa0')
+ // $FlowFixMe
+ return humanDate(datems).replaceAll(' ', '\xa0')
}
if (interval >= DAYS_MILLIS) {
return L('1d')
diff --git a/test/cypress/integration/agroup-contributions.spec.js b/test/cypress/integration/agroup-contributions.spec.js
new file mode 100644
index 0000000000..0e09a79290
--- /dev/null
+++ b/test/cypress/integration/agroup-contributions.spec.js
@@ -0,0 +1,565 @@
+const userId = Math.floor(Math.random() * 10000)
+const groupName = 'Dreamers'
+const usersDisplayName = {
+ 1: 'Greg',
+ 2: 'Margarida',
+ 3: 'Pierre',
+ 4: 'Sandrina'
+}
+
+const elReceivingFirst = '.receiving .c-contribution-item:first-child'
+const elGivingFirst = '.giving .c-contribution-item:first-child'
+
+function addNonMonetaryContribution (name) {
+ cy.getByDT('addNonMonetaryContribution', 'button').click()
+ cy.getByDT('inputNonMonetaryContribution').type(name)
+ cy.getByDT('buttonAddNonMonetaryContribution', 'button').click()
+ cy.getByDT('buttonAddNonMonetaryContribution', 'button').should('not.exist')
+
+ // Assert the contribution was added to the list once
+ cy.getByDT('givingList').should($list => {
+ const contribution = $list.find('li').filter((i, item) => {
+ return item.textContent.includes(name) && item.getAttribute('data-test') === 'editable'
+ })
+ expect(contribution).to.have.length(1)
+ })
+}
+
+function assertNonMonetaryEditableValue (name) {
+ cy.getByDT('buttonEditNonMonetaryContribution').click()
+ cy.getByDT('inputNonMonetaryContribution').should('have.value', name)
+ cy.getByDT('buttonSaveNonMonetaryContribution').click()
+}
+
+function assertGraphicSummary (legendListItems) {
+ cy.getByDT('groupPledgeSummary', 'ul').within(([list]) => {
+ legendListItems.forEach((legendText, index) => {
+ cy.get(list).children().eq(index)
+ .invoke('text')
+ .should('contain', legendText)
+ })
+ })
+}
+
+function assertContributionsWidget (assertions) {
+ cy.getByDT('dashboard', 'a').click()
+ cy.getByDT('contributionsWidget').within(() => {
+ Object.keys(assertions).forEach(dataTest => {
+ cy.getByDT(dataTest).should('contain', assertions[dataTest])
+ })
+ })
+ cy.getByDT('contributionsLink', 'a').click()
+}
+
+function updateIncome (newIncome, needsIncome, graphicLegend, incomeStatus) {
+ cy.getByDT('contributionsLink').click()
+ cy.getByDT('openIncomeDetailsModal').click()
+ cy.getByDT(needsIncome ? 'needsIncomeRadio' : 'doesntNeedIncomeRadio').click()
+ cy.getByDT('inputIncomeOrPledge').clear().type(newIncome)
+
+ assertGraphicSummary(graphicLegend)
+
+ if (needsIncome) {
+ // entering the payment details is mandatory for 'needsIncome'
+ cy.randomPaymentMethodInIncomeDetails()
+ }
+
+ cy.getByDT('submitIncome').click()
+ cy.getByDT('closeModal').should('not.exist') // make sure the modal closes.
+
+ const elIncomeStatus = needsIncome ? elReceivingFirst : elGivingFirst
+ cy.get(elIncomeStatus).should('contain', incomeStatus)
+}
+
+describe('Contributions', () => {
+ const invitationLinks = {}
+
+ it('user1 creates a group', () => {
+ cy.visit('/')
+ cy.giSignup(`user1-${userId}`, { bypassUI: true })
+
+ cy.giCreateGroup(groupName, { bypassUI: true })
+
+ cy.giSetDisplayName(usersDisplayName[1])
+
+ cy.giGetInvitationAnyone().then(url => {
+ invitationLinks.anyone = url
+ })
+
+ cy.giLogout()
+ })
+
+ it('user2, user3 and user4 join the group', () => {
+ for (let i = 2; i <= 4; i++) {
+ cy.giAcceptGroupInvite(invitationLinks.anyone, {
+ username: `user${i}-${userId}`,
+ groupName,
+ displayName: usersDisplayName[i],
+ bypassUI: true
+ })
+ }
+
+ cy.giLogin(`user1-${userId}`, { bypassUI: true })
+ })
+
+ it('user1 fills their Income Details - pledges $500', () => {
+ cy.getByDT('contributionsLink').click()
+ cy.getByDT('addIncomeDetailsCard').should('contain', 'Add your income details')
+
+ cy.getByDT('openIncomeDetailsModal').click()
+ // Make sure only radio box to select the type is visible at the begining
+ cy.getByDT('introIncomeOrPledge').should('not.exist')
+
+ cy.getByDT('doesntNeedIncomeRadio').click()
+ // Make sure the user is aksed how much he want to pledge
+ cy.getByDT('introIncomeOrPledge').should('contain', 'How much do you want to pledge?')
+
+ assertGraphicSummary([
+ 'Total Pledged$0',
+ 'Needed Pledges$0'
+ ])
+
+ // Users should be allowed to pledge 0 (see #1027).
+ cy.getByDT('inputIncomeOrPledge').type('0')
+ cy.getByDT('badIncome').should('not.be.visible')
+
+ // Users should not be allowed to pledge a negative amount.
+ cy.getByDT('inputIncomeOrPledge').clear().type('-50')
+ cy.getByDT('badIncome').should('be.visible')
+ .and('contain', 'Oops, you entered a negative number')
+
+ assertGraphicSummary([
+ 'Total Pledged$0',
+ 'Needed Pledges$0'
+ ])
+
+ cy.getByDT('inputIncomeOrPledge').clear().type(500)
+
+ assertGraphicSummary([
+ 'Total Pledged$500',
+ 'Needed Pledges$0'
+ ])
+
+ cy.getByDT('submitIncome').click()
+ // After selecting the amount and close the modal make sure it show that no one is in need
+ cy.getByDT('receivingParagraph').should('contain', 'When other members pledge a monetary or non-monetary contribution, they will appear here.')
+ cy.getByDT('givingParagraph').should('contain', 'No one needs monetary contributions at the moment. You can still add non-monetary contributions if you would like.')
+
+ assertContributionsWidget({
+ paymentsTitle: 'Payments sent',
+ paymentsStatus: 'At the moment, no one is in need of contributions.',
+ monetaryTitle: 'You are pledging $500',
+ monetaryStatus: '$0 will be used.',
+ nonMonetaryStatus: 'There are no non-monetary contributions.'
+ })
+ })
+
+ it('user1 decides to switch income details to needing $100 and add a payment info', () => {
+ cy.getByDT('openIncomeDetailsModal').click()
+ cy.getByDT('needsIncomeRadio').click()
+ // After swithing to need income, it should ask user how much he need
+ cy.getByDT('introIncomeOrPledge').should('contain', 'What\'s your monthly income?')
+ cy.getByDT('inputIncomeOrPledge').type(500)
+ // It should not let user ask for money if he has more than the basic income
+ cy.getByDT('badIncome').should('contain', 'Your income must be lower than the group mincome')
+ cy.getByDT('inputIncomeOrPledge').clear().type(100)
+ // After updating the income under the limit it should hide the error message
+ cy.getByDT('badIncome').should('not.be.visible')
+
+ assertGraphicSummary([
+ 'Total Pledged$0',
+ 'Needed Pledges$100'
+ ])
+
+ cy.getByDT('submitIncome').click()
+ // When 'need income' is selected, payment details is requried.
+ cy.getByDT('feedbackMsg').should('contain', 'Payment details required. Please let people know how they can pay you.')
+
+ // Fill out the payment details (bitcoin)
+ cy.getByDT('paymentMethods').within(() => {
+ cy.getByDT('fields', 'ul').children().should('have.length', 1)
+
+ cy.log('Fill the 1º payment method (bitcoin)')
+ cy.getByDT('method').within(() => {
+ cy.getByDT('remove', 'button').should('not.be.visible')
+ cy.get('select')
+ .should('have.value', null)
+ .select('bitcoin')
+ cy.get('input').type('h4sh-t0-b3-s4ved')
+ cy.getByDT('remove', 'button').should('be.visible')
+ })
+ })
+
+ cy.getByDT('submitIncome').click()
+ cy.getByDT('closeModal').should('not.exist')
+
+ // After closing the modal it should dislay how much user need
+ cy.getByDT('headerNeed').should('contain', 'You need $100')
+ // The user should be inform that even if he can't pledge he can still contribute
+ cy.getByDT('givingParagraph').should('contain', 'You can contribute to your group with money or other valuables like teaching skills, sharing your time to help someone. The sky is the limit!')
+
+ assertContributionsWidget({
+ paymentsTitle: 'Payments received',
+ paymentsStatus: 'No members in the group are pledging yet! 😔',
+ monetaryTitle: 'You need $100',
+ monetaryStatus: 'You will receive $0.',
+ nonMonetaryStatus: 'There are no non-monetary contributions.'
+ })
+ })
+
+ it('user1 adds additional payment info', () => {
+ cy.getByDT('openIncomeDetailsModal').click()
+
+ cy.getByDT('paymentMethods').within(() => {
+ cy.log('Add a 2º payment method (paypal)')
+ cy.getByDT('addMethod', 'button').click()
+ cy.getByDT('fields', 'ul').children().should('have.length', 2)
+
+ cy.getByDT('method').eq(1).within(() => {
+ cy.getByDT('remove', 'button').should('be.visible')
+ cy.get('select').should('have.value', null)
+ cy.get('input').should('have.value', '')
+ cy.get('select').select('paypal')
+ cy.get('input').type('user1-paypal@email.com')
+ })
+
+ cy.log('Add a 3º payment method (other)')
+ cy.getByDT('addMethod', 'button').click()
+ cy.getByDT('fields', 'ul').children().should('have.length', 3)
+
+ cy.getByDT('method').eq(2).within(() => {
+ cy.get('select').should('have.value', null)
+ cy.get('input').should('have.value', '')
+ cy.get('select').select('other')
+ cy.get('input').type('IBAN: 12345')
+ cy.getByDT('remove', 'button').should('be.visible')
+ })
+
+ cy.log('Remove the 2º payment method (paypal)')
+ cy.getByDT('method').eq(1).within(() => {
+ cy.getByDT('remove', 'button').click()
+ })
+
+ cy.getByDT('fields', 'ul').children().should('have.length', 2)
+
+ cy.log('Add a 3º same payment method (other)')
+ cy.getByDT('addMethod', 'button').click()
+ cy.getByDT('method').eq(2).within(() => {
+ cy.get('select').should('have.value', null)
+ cy.get('input').should('have.value', '')
+ cy.get('select').select('other')
+ cy.get('input').type('MBWAY: 91 2345678')
+ cy.getByDT('remove', 'button').should('be.visible')
+ })
+ })
+
+ cy.getByDT('submitIncome').click()
+ cy.getByDT('closeModal').should('not.exist')
+
+ cy.log('Verify saved payment info (bitcoin and 2 other)')
+ cy.getByDT('openIncomeDetailsModal').click()
+ // HACK FOR A BIZARRE HEISENBUGG!!!
+ // Description: without this, sometimes the payment methods do not appear
+ // in the list for some reason, but they re-appear if we close and open the modal
+ cy.closeModal()
+ cy.getByDT('openIncomeDetailsModal').click()
+ // HACK FOR A BIZARRE HEISENBUGG!!!
+ cy.getByDT('paymentMethods').within(() => {
+ cy.getByDT('fields', 'ul').children().should('have.length', 3)
+ cy.getByDT('method').eq(0).within(() => {
+ cy.get('select').should('have.value', 'bitcoin')
+ cy.get('input').should('have.value', 'h4sh-t0-b3-s4ved')
+ cy.getByDT('remove', 'button').should('be.visible')
+ })
+ cy.getByDT('method').eq(1).within(() => {
+ cy.get('select').should('have.value', 'other')
+ cy.get('input').should('have.value', 'IBAN: 12345')
+ cy.getByDT('remove', 'button').should('be.visible')
+ })
+ cy.getByDT('method').eq(2).within(() => {
+ cy.get('select').should('have.value', 'other')
+ cy.get('input').should('have.value', 'MBWAY: 91 2345678')
+ cy.getByDT('remove', 'button').should('be.visible')
+ })
+
+ cy.log('Try to add a 4º payment method - incompleted !name')
+ cy.getByDT('addMethod', 'button').click()
+ cy.getByDT('method').eq(3).within(() => {
+ cy.get('input').type('mylink.com')
+ })
+ })
+
+ cy.getByDT('submitIncome').click()
+ cy.getByDT('feedbackMsg').should('contain', 'The method name for "mylink.com" is missing.')
+
+ cy.getByDT('paymentMethods').within(() => {
+ // Remove the previous incomplete method
+ cy.getByDT('method').eq(3).within(() => {
+ cy.getByDT('remove', 'button').click()
+ })
+
+ cy.log('Try to add a 4º payment method - incompleted !value')
+ // Add a new method... incompleted (no value)
+ cy.getByDT('addMethod', 'button').click()
+ cy.getByDT('method').eq(3).within(() => {
+ cy.get('select').select('paypal')
+ })
+ })
+
+ cy.getByDT('submitIncome').click()
+ cy.getByDT('feedbackMsg').should('contain', 'The method "paypal" is incomplete.')
+
+ cy.closeModal()
+ })
+
+ it('user1 have their payment info on the profile card', () => {
+ cy.getByDT('openProfileCard').click()
+
+ cy.getByDT('profilePaymentMethods').within(() => {
+ cy.get('ul').children().should('have.length', 3)
+ cy.getByDT('profilePaymentMethod').eq(0).within(() => {
+ cy.get('span').eq(0).should('contain', 'bitcoin')
+ cy.get('span').eq(1).should('contain', 'h4sh-t0-b3-s4ved')
+ })
+ cy.getByDT('profilePaymentMethod').eq(1).within(() => {
+ cy.get('span').eq(0).should('contain', 'other')
+ cy.get('span').eq(1).should('contain', 'IBAN: 12345')
+ })
+ cy.getByDT('profilePaymentMethod').eq(2).within(() => {
+ cy.get('span').eq(0).should('contain', 'other')
+ cy.get('span').eq(1).should('contain', 'MBWAY: 91 2345678')
+ })
+ })
+ cy.getByDT('closeProfileCard').click()
+ })
+
+ const firstContribution = 'Portuguese classes'
+
+ it('user1 adds non monetary contribution', () => {
+ addNonMonetaryContribution(firstContribution)
+
+ cy.getByDT('givingList', 'ul')
+ .get('li.is-editable')
+ .should('have.length', 1)
+ .should('contain', firstContribution)
+ })
+
+ it('user1 removes non monetary contribution', () => {
+ cy.getByDT('buttonEditNonMonetaryContribution')
+
+ cy.getByDT('givingList').find('li').should('have.length', 2) // contribution + cta to add
+ cy.getByDT('buttonEditNonMonetaryContribution').click()
+ cy.getByDT('buttonRemoveNonMonetaryContribution').click()
+ cy.getByDT('givingList').find('li').should('have.length', 1) // cta to add
+ cy.getByDT('givingParagraph').should('exist')
+ })
+
+ it('user1 re-adds the same non monetary contribution', () => {
+ addNonMonetaryContribution(firstContribution)
+ cy.getByDT('givingList', 'ul')
+ .get('li.is-editable')
+ .should('have.length', 1)
+ .should('contain', firstContribution)
+ })
+
+ it('user1 edits the non monetary contribution', () => {
+ cy.getByDT('buttonEditNonMonetaryContribution').click()
+ cy.getByDT('inputNonMonetaryContribution').clear().type('French classes{enter}')
+ assertNonMonetaryEditableValue('French classes')
+ // Double check // TODO - Why do we need this?
+ assertNonMonetaryEditableValue('French classes')
+
+ cy.getByDT('givingList', 'ul')
+ .get('li.is-editable')
+ .should('have.length', 1)
+ .should('contain', 'French classes')
+ })
+
+ it('user1 edits it again but cancel it', () => {
+ cy.getByDT('buttonEditNonMonetaryContribution').click()
+ cy.getByDT('buttonCancelNonMonetaryContribution').click()
+ cy.getByDT('givingList', 'ul')
+ .get('li.is-editable')
+ .should('have.length', 1)
+ .should('contain', 'French classes')
+ })
+
+ it('user1 adds 3 more non monetary contributions', () => {
+ addNonMonetaryContribution('German classes')
+ addNonMonetaryContribution('Russian classes')
+ addNonMonetaryContribution('Korean classes')
+
+ cy.getByDT('givingList', 'ul')
+ .get('li.is-editable')
+ .should('have.length', 4)
+
+ assertContributionsWidget({
+ nonMonetaryStatus: 'You are contributing.'
+ })
+ })
+
+ it('user1 have their payment info on the member list profile card', () => {
+ cy.getByDT('dashboard', 'a').click()
+ cy.getByDT('openMemberProfileCard').eq(0).click()
+
+ cy.log('The first member card should not contain payment info')
+ cy.getByDT('profilePaymentMethods').should('not.exist')
+ cy.getByDT('closeProfileCard').click()
+
+ cy.log('The last member card should contain payments info')
+ cy.getByDT('openMemberProfileCard').eq(3).click()
+ cy.getByDT('profilePaymentMethods').within(() => {
+ cy.get('ul').children().should('have.length', 3)
+ cy.getByDT('profilePaymentMethod').eq(0).within(() => {
+ cy.get('span').eq(0).should('contain', 'bitcoin')
+ cy.get('span').eq(1).should('contain', 'h4sh-t0-b3-s4ved')
+ })
+ cy.getByDT('profilePaymentMethod').eq(1).within(() => {
+ cy.get('span').eq(0).should('contain', 'other')
+ cy.get('span').eq(1).should('contain', 'IBAN: 12345')
+ })
+ cy.getByDT('profilePaymentMethod').eq(2).within(() => {
+ cy.get('span').eq(0).should('contain', 'other')
+ cy.get('span').eq(1).should('contain', 'MBWAY: 91 2345678')
+ })
+ })
+
+ cy.getByDT('closeProfileCard').click(('topLeft'))
+ })
+
+ it('user2 pledges $100 and sees their contributions.', () => {
+ cy.giSwitchUser(`user2-${userId}`)
+
+ const graphicLegend = [
+ 'Total Pledged$100',
+ 'Needed Pledges$0'
+ ]
+ updateIncome(100, false, graphicLegend, '$100 to Greg')
+
+ cy.get(elReceivingFirst)
+ .should('contain', 'French classes from Greg')
+
+ cy.get('.receiving .c-contribution-list')
+ .should('have.length', 4)
+ })
+
+ it('user2 adds 2 non monetary contribution', () => {
+ addNonMonetaryContribution('Korean classes')
+ addNonMonetaryContribution('French classes')
+
+ cy.get('.giving .c-contribution-list')
+ .should('have.length', 3)
+
+ assertContributionsWidget({
+ paymentsSummary: ' ', // TODO - just confirm it exists for now.
+ monetaryTitle: 'You are pledging $100',
+ monetaryStatus: '$100 will be used.',
+ nonMonetaryStatus: 'You and 1 other members are contributing.'
+ })
+ })
+
+ it('user3 pledges $100 and sees who they are pledging to - $50 to user1 (Greg)', () => {
+ cy.giSwitchUser(`user3-${userId}`)
+ const graphicLegend = [
+ 'Total Pledged$200',
+ 'Needed Pledges$0'
+ ]
+ updateIncome(100, false, graphicLegend, '$50 to Greg')
+ })
+
+ it('user4 and user2 increase their pledges to $500 each. user1 sees the receiving contributions from 3 members.', () => {
+ cy.giSwitchUser(`user4-${userId}`)
+ const graphicLegend4 = [
+ 'Total Pledged$700',
+ 'Needed Pledges$0',
+ 'Surplus$600'
+ ]
+ updateIncome(500, false, graphicLegend4, '$71.43 to Greg')
+ addNonMonetaryContribution('Korean classes')
+
+ cy.giSwitchUser(`user2-${userId}`)
+ const graphicLegend2 = [
+ 'Total Pledged$1100',
+ 'Needed Pledges$0',
+ 'Surplus$1000'
+ ]
+ updateIncome(500, false, graphicLegend2, '$45.45 to Greg')
+
+ cy.giSwitchUser(`user1-${userId}`)
+
+ cy.getByDT('contributionsLink').click()
+ cy.get(elReceivingFirst).should('contain', '$100 from 3 members')
+
+ assertContributionsWidget({
+ paymentsSummary: ' ', // TODO - just confirm it exists for now.
+ monetaryTitle: 'You need $100',
+ monetaryStatus: 'You will receive $100.',
+ nonMonetaryStatus: 'You and 2 other members are contributing.'
+ })
+ })
+
+ it('user4 and user2 reduced income to $10 and now receive money.', () => {
+ cy.giSwitchUser(`user4-${userId}`)
+ const graphicLegend4 = [
+ 'Total Pledged$600',
+ 'Needed Pledges$0',
+ 'Surplus$310',
+ "You'll receive$190"
+ ]
+ updateIncome(10, true, graphicLegend4, '$190 from Margarida and Pierre')
+
+ cy.giSwitchUser(`user2-${userId}`)
+ const graphicLegend2 = [
+ 'Total Pledged$100',
+ 'Needed Pledges$380',
+ "You'll receive$39.58"
+ ]
+ updateIncome(10, true, graphicLegend2, '$39.58 from Pierre')
+
+ assertContributionsWidget({
+ paymentsSummary: ' ', // TODO - just confirm it exists for now.
+ monetaryTitle: 'You need $190',
+ monetaryStatus: 'You will receive $39.58.',
+ nonMonetaryStatus: 'You and 2 other members are contributing.'
+ })
+ })
+
+ it('user3 pledges to all 3 members', () => {
+ cy.giSwitchUser(`user3-${userId}`)
+ cy.getByDT('contributionsLink').click()
+
+ cy.get(elGivingFirst)
+ .should('contain', 'A total of $100 to 3 members')
+ })
+
+ it('user1 receives part of what they need', () => {
+ cy.giSwitchUser(`user1-${userId}`)
+ cy.getByDT('contributionsLink').click()
+
+ cy.get(elReceivingFirst)
+ .should('contain', '$20.83 from Pierre')
+
+ assertContributionsWidget({
+ paymentsSummary: ' ', // TODO - just confirm it exists for now.
+ monetaryTitle: 'You need $100',
+ monetaryStatus: 'You will receive $20.83.',
+ nonMonetaryStatus: 'You and 2 other members are contributing.'
+ })
+ cy.giLogout()
+ })
+})
+
+/*
+Summary of the group status so far:
+user1
+ - needs $100
+ - $20.83 from pierre
+user2
+ - needs $190
+ - $39.58 from pierre
+user3
+ - pledges $100 to user1, user2 and user4
+user4
+ - needs $190
+ - $39.58 from pierre
+*/