Merge branch 'grass_instead_of_sass' of github.com:gradido/gradido into grass_instead_of_sass

This commit is contained in:
einhornimmond 2025-05-20 12:38:44 +02:00
commit d76f1d9c6f
87 changed files with 1969 additions and 1940 deletions

View File

@ -35,7 +35,7 @@ jobs:
##########################################################################
- name: Frontend | Build `production` image
run: |
docker build --target production -t "gradido/frontend:latest" -t "gradido/frontend:production" -t "gradido/frontend:${VERSION}" -t "gradido/frontend:${BUILD_VERSION}" frontend/
docker build -f ./frontend/Dockerfile --target production -t "gradido/frontend:latest" -t "gradido/frontend:production" -t "gradido/frontend:${VERSION}" -t "gradido/frontend:${BUILD_VERSION}" .
docker save "gradido/frontend" > /tmp/frontend.tar
- name: Upload Artifact
uses: actions/upload-artifact@v4
@ -43,6 +43,43 @@ jobs:
name: docker-frontend-production
path: /tmp/frontend.tar
##############################################################################
# JOB: DOCKER BUILD PRODUCTION ADMIN #########################################
##############################################################################
build_production_admin:
name: Docker Build Production - Admin
runs-on: ubuntu-latest
#needs: [nothing]
steps:
##########################################################################
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v4
##########################################################################
# SET ENVS ###############################################################
##########################################################################
- name: ENV - VERSION
run: echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
- name: ENV - BUILD_DATE
run: echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
- name: ENV - BUILD_VERSION
run: echo "BUILD_VERSION=${VERSION}.${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
- name: ENV - BUILD_COMMIT
run: echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
##########################################################################
# ADMIN ##################################################################
##########################################################################
- name: Admin | Build `production` image
run: |
docker build -f ./admin/Dockerfile --target production -t "gradido/admin:latest" -t "gradido/admin:production" -t "gradido/admin:${VERSION}" -t "gradido/admin:${BUILD_VERSION}" .
docker save "gradido/admin" > /tmp/admin.tar
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: docker-admin-production
path: /tmp/admin.tar
##############################################################################
# JOB: DOCKER BUILD PRODUCTION BACKEND #######################################
##############################################################################
@ -170,14 +207,14 @@ jobs:
##########################################################################
# DATABASE UP ############################################################
##########################################################################
- name: Database | Build `production_up` image
- name: Database | Build `up` image
run: |
docker build --target production_up -t "gradido/database:production_up" database/
docker save "gradido/database:production_up" > /tmp/database_up.tar
docker build --target up -t "gradido/database:up" database/
docker save "gradido/database:up" > /tmp/database_up.tar
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: docker-database-production_up
name: docker-database-up
path: /tmp/database_up.tar
##############################################################################
@ -243,6 +280,13 @@ jobs:
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/frontend.tar
- name: Download Docker Image (Admin)
uses: actions/download-artifact@v4
with:
name: docker-admin-production
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/admin.tar
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v4
with:
@ -267,7 +311,7 @@ jobs:
- name: Download Docker Image (Database)
uses: actions/download-artifact@v4
with:
name: docker-database-production_up
name: docker-database-up
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/database_up.tar
@ -287,6 +331,8 @@ jobs:
run: echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin
- name: Push frontend
run: docker push --all-tags gradido/frontend
- name: Push admin
run: docker push --all-tags gradido/admin
- name: Push backend
run: docker push --all-tags gradido/backend
- name: Push dht-node

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
*.log
*.bak
.turbo
vite.config.mjs.timestamp-*
/node_modules/*
messages.pot
nbproject

View File

@ -14,9 +14,9 @@ ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
ARG BUILD_VERSION
ENV BUILD_VERSION=${BUILD_VERSION:-'broken'}
ARG BUILD_COMMIT
ENV BUILD_COMMIT=${BUILD_COMMIT:-'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'}
ENV BUILD_COMMIT=${BUILD_COMMIT:-'decafcabdecafcabdecafcabdecafcabdecafcab'}
ARG BUILD_COMMIT_SHORT
ENV BUILD_COMMIT_SHORT=${BUILD_COMMIT_SHORT:-'deadbeef'}
ENV BUILD_COMMIT_SHORT=${BUILD_COMMIT_SHORT:-'decafcab'}
## SET NODE_ENV
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}

View File

@ -11,12 +11,15 @@
"start": "vite preview",
"postbuild": "uname | grep -q Linux && find build -type f -regex '.*\\.\\(html\\|js\\|css\\|svg\\|json\\)' -exec gzip -9 -k {} + || echo 'Skip precompress on non-Linux'",
"lint": "eslint --max-warnings=0 --ext .js,.vue,.json .",
"lint:fix": "eslint --max-warnings=0 --ext .js,.vue,.json . --fix",
"stylelint": "stylelint --max-warnings=0 '**/*.{scss,vue}'",
"stylelint:fix": "stylelint --max-warnings=0 '**/*.{scss,vue}' --fix",
"test": "cross-env TZ=UTC vitest run",
"test:coverage": "cross-env TZ=UTC vitest run --coverage",
"test:debug": "cross-env TZ=UTC node --inspect-brk ./node_modules/vitest/vitest.mjs",
"test:watch": "cross-env TZ=UTC vitest",
"locales": "scripts/sort.sh"
"locales": "scripts/sort.sh",
"locales:fix": "scripts/sort.sh --fix"
},
"dependencies": {
"@iconify/json": "^2.2.228",

View File

@ -108,7 +108,7 @@ describe('ContributionMessagesList', () => {
id: 42,
memo: 'test memo',
userId: 108,
status: 'PENDING',
contributionStatus: 'PENDING',
user: defaultUser,
},
hideResubmission: true,
@ -153,7 +153,7 @@ describe('ContributionMessagesList', () => {
it('does not render the ContributionMessagesFormular when status is not PENDING or IN_PROGRESS', async () => {
await wrapper.setProps({
contribution: {
status: 'COMPLETED',
contributionStatus: 'COMPLETED',
user: defaultUser,
},
})

View File

@ -41,7 +41,12 @@
/>
</div>
</BContainer>
<div v-if="contribution.status === 'PENDING' || contribution.status === 'IN_PROGRESS'">
<div
v-if="
contribution.contributionStatus === 'PENDING' ||
contribution.contributionStatus === 'IN_PROGRESS'
"
>
<contribution-messages-formular
:contribution-id="contribution.id"
:contribution-memo="contribution.memo"

View File

@ -92,7 +92,7 @@ describe('CreationTransactionList', () => {
expect(fields[0].key).toBe('createdAt')
expect(fields[1].key).toBe('contributionDate')
expect(fields[2].key).toBe('confirmedAt')
expect(fields[3].key).toBe('status')
expect(fields[3].key).toBe('contributionStatus')
expect(fields[4].key).toBe('amount')
expect(fields[5].key).toBe('memo')
})

View File

@ -80,7 +80,7 @@ const fields = [
},
},
{
key: 'status',
key: 'contributionStatus',
label: t('transactionlist.status'),
},
{

View File

@ -19,9 +19,13 @@ describe('Overlay', () => {
amount: '100',
contributionDate: '2023-01-15T00:00:00.000Z',
memo: 'Test memo',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
user: {
firstName: 'John',
lastName: 'Doe',
emailContact: {
email: 'john.doe@example.com',
},
},
}
beforeEach(() => {

View File

@ -23,11 +23,11 @@
</BRow>
<BRow class="mt-3">
<BCol class="col-3">{{ $t('name') }}</BCol>
<BCol>{{ item.firstName }} {{ item.lastName }}</BCol>
<BCol>{{ item.user.firstName }} {{ item.user.lastName }}</BCol>
</BRow>
<BRow>
<BCol class="col-3">{{ $t('e_mail') }}</BCol>
<BCol>{{ item.email }}</BCol>
<BCol>{{ item.user.emailContact.email }}</BCol>
</BRow>
<hr class="my-4" />

View File

@ -6,7 +6,7 @@
:icon="type === 'PageCreationConfirm' ? 'x' : 'eye-slash-fill'"
aria-label="Help"
></b-icon>
{{ $t('hide_details') }} {{ row.item.firstName }} {{ row.item.lastName }}
{{ $t('hide_details') }} {{ row.item.user.firstName }} {{ row.item.user.lastName }}
</b-button>
</b-card>
</template>

View File

@ -14,12 +14,12 @@ describe('OpenCreationsTable', () => {
let store
const mockItems = [
{ id: 1, status: 'PENDING', userId: 2, moderatorId: null, messagesCount: 0 },
{ id: 2, status: 'CONFIRMED', userId: 3, moderatorId: 1, messagesCount: 2 },
{ id: 1, contributionStatus: 'PENDING', userId: 2, moderatorId: null, messagesCount: 0 },
{ id: 2, contributionStatus: 'CONFIRMED', userId: 3, moderatorId: 1, messagesCount: 2 },
]
const mockFields = [
{ key: 'status', label: 'Status' },
{ key: 'contributionStatus', label: 'Status' },
{ key: 'bookmark', label: 'Bookmark' },
{ key: 'memo', label: 'Memo' },
{ key: 'editCreation', label: 'Edit' },
@ -72,7 +72,7 @@ describe('OpenCreationsTable', () => {
})
it('applies correct row class based on status', () => {
const rowClass = wrapper.vm.rowClass({ status: 'CONFIRMED' }, 'row')
const rowClass = wrapper.vm.rowClass({ contributionStatus: 'CONFIRMED' }, 'row')
expect(rowClass).toBe('table-success')
})

View File

@ -9,12 +9,12 @@
stacked="md"
:tbody-tr-class="rowClass"
>
<template #cell(status)="row">
<IBiQuestionSquare v-if="row.item.status === 'IN_PROGRESS'" />
<IBiBellFill v-else-if="row.item.status === 'PENDING'" />
<IBiCheck v-else-if="row.item.status === 'CONFIRMED'" />
<IBiXCircle v-else-if="row.item.status === 'DENIED'" />
<IBiTrash v-else-if="row.item.status === 'DELETED'" />
<template #cell(contributionStatus)="row">
<IBiQuestionSquare v-if="row.item.contributionStatus === 'IN_PROGRESS'" />
<IBiBellFill v-else-if="row.item.contributionStatus === 'PENDING'" />
<IBiCheck v-else-if="row.item.contributionStatus === 'CONFIRMED'" />
<IBiXCircle v-else-if="row.item.contributionStatus === 'DENIED'" />
<IBiTrash v-else-if="row.item.contributionStatus === 'DELETED'" />
</template>
<template #cell(bookmark)="row">
<div v-if="!myself(row.item)">
@ -51,11 +51,11 @@
<BButton v-else @click="rowToggleDetails(row, 0)">
<IBiChatDots />
<IBiExclamationCircleFill
v-if="row.item.status === 'PENDING' && row.item.messagesCount > 0"
v-if="row.item.contributionStatus === 'PENDING' && row.item.messagesCount > 0"
style="color: #ffc107"
/>
<IBiQuestionDiamond
v-if="row.item.status === 'IN_PROGRESS' && row.item.messagesCount > 0"
v-if="row.item.contributionStatus === 'IN_PROGRESS' && row.item.messagesCount > 0"
variant="warning"
style="color: #ffc107"
class="ps-1"
@ -197,11 +197,11 @@ export default {
},
rowClass(item, type) {
if (!item || type !== 'row') return
if (item.status === 'CONFIRMED') return 'table-success'
if (item.status === 'DENIED') return 'table-warning'
if (item.status === 'DELETED') return 'table-danger'
if (item.status === 'IN_PROGRESS') return 'table-primary'
if (item.status === 'PENDING') return 'table-primary'
if (item.contributionStatus === 'CONFIRMED') return 'table-success'
if (item.contributionStatus === 'DENIED') return 'table-warning'
if (item.contributionStatus === 'DELETED') return 'table-danger'
if (item.contributionStatus === 'IN_PROGRESS') return 'table-primary'
if (item.contributionStatus === 'PENDING') return 'table-primary'
},
updateStatus(id) {
this.$emit('update-status', id)

View File

@ -116,7 +116,7 @@ describe('TransactionLinkList', () => {
})
it('formats status correctly for different scenarios', () => {
const statusField = wrapper.vm.fields.find((f) => f.key === 'status')
const statusField = wrapper.vm.fields.find((f) => f.key === 'contributionStatus')
// Open transaction
expect(statusField.formatter(null, null, mockLinks[0])).toBe('open')

View File

@ -54,7 +54,7 @@ const fields = computed(() => [
formatter: (value) => d(new Date(value)),
},
{
key: 'status',
key: 'contributionStatus',
label: 'status',
formatter: (value, key, item) => {
if (item.deletedAt) return `${t('deleted')}: ${d(new Date(item.deletedAt))}`

View File

@ -25,7 +25,7 @@ query adminListContributions(
confirmedBy
updatedAt
updatedBy
status
contributionStatus
messagesCount
deniedAt
deniedBy
@ -54,7 +54,7 @@ query adminListContributionsShort(
createdAt
contributionDate
confirmedAt
status
contributionStatus
}
}
}

View File

@ -224,7 +224,7 @@ const fields = computed(
],
// all contributions
[
{ key: 'status', label: t('status') },
{ key: 'contributionStatus', label: t('status') },
baseFields.firstName,
baseFields.lastName,
baseFields.amount,
@ -425,7 +425,7 @@ const updateStatus = (id) => {
const target = items.value.find((obj) => obj.id === id)
if (target) {
target.messagesCount++
target.status = 'IN_PROGRESS'
target.contributionStatus = 'IN_PROGRESS'
}
}
</script>

View File

@ -129,7 +129,7 @@ const fields = computed(() => [
// { key: 'confirm_mail', label: t('confirmed') },
// { key: 'has_elopage', label: 'elopage' },
// { key: 'transactions_list', label: t('transaction') },
{ key: 'status', label: t('status') },
{ key: 'contributionStatus', label: t('status') },
])
watch(

View File

@ -5,6 +5,11 @@
"locales": {},
"lint": {
"dependsOn": ["stylelint", "locales"]
},
"stylelint:fix": {},
"locales:fix": {},
"lint:fix": {
"dependsOn": ["stylelint:fix", "locales:fix"]
}
}
}

View File

@ -20,6 +20,7 @@
"lint:fix": "biome check --error-on-warnings . --write",
"lint:fix:unsafe": "biome check --fix --unsafe",
"locales": "scripts/sort.sh",
"locales:fix": "scripts/sort.sh --fix",
"start": "cross-env TZ=UTC NODE_ENV=production node build/index.js",
"typecheck": "tsc --noEmit"
},

View File

@ -20,5 +20,5 @@ export class ContributionArgs {
@Field(() => String)
@isValidDateString()
creationDate: string
contributionDate: string
}

View File

@ -1,58 +1,26 @@
import { User as DbUser, Contribution as dbContribution } from 'database'
import { Decimal } from 'decimal.js-light'
import { Contribution as DbContribution } from 'database'
import { Field, Int, ObjectType } from 'type-graphql'
import { User } from './User'
import { UnconfirmedContribution } from './UnconfirmedContribution'
@ObjectType()
export class Contribution {
constructor(contribution: dbContribution, user?: DbUser | null) {
this.id = contribution.id
this.firstName = user?.firstName ?? null
this.lastName = user?.lastName ?? null
this.amount = contribution.amount
this.memo = contribution.memo
this.createdAt = contribution.createdAt
this.confirmedAt = contribution.confirmedAt
this.confirmedBy = contribution.confirmedBy
this.contributionDate = contribution.contributionDate
this.status = contribution.contributionStatus
this.messagesCount = contribution.messages ? contribution.messages.length : 0
this.deniedAt = contribution.deniedAt
this.deniedBy = contribution.deniedBy
this.deletedAt = contribution.deletedAt
this.deletedBy = contribution.deletedBy
this.updatedAt = contribution.updatedAt
this.updatedBy = contribution.updatedBy
this.moderatorId = contribution.moderatorId
this.userId = contribution.userId
this.resubmissionAt = contribution.resubmissionAt
if (user) {
this.user = new User(user)
}
export class Contribution extends UnconfirmedContribution {
constructor(dbContribution: DbContribution) {
super(dbContribution)
this.createdAt = dbContribution.createdAt
this.confirmedAt = dbContribution.confirmedAt
this.confirmedBy = dbContribution.confirmedBy
this.contributionDate = dbContribution.contributionDate
this.deniedAt = dbContribution.deniedAt
this.deniedBy = dbContribution.deniedBy
this.deletedAt = dbContribution.deletedAt
this.deletedBy = dbContribution.deletedBy
this.updatedAt = dbContribution.updatedAt
this.updatedBy = dbContribution.updatedBy
this.resubmissionAt = dbContribution.resubmissionAt
}
@Field(() => Int)
id: number
@Field(() => Int, { nullable: true })
userId: number | null
@Field(() => User, { nullable: true })
user: User | null
@Field(() => String, { nullable: true })
firstName: string | null
@Field(() => String, { nullable: true })
lastName: string | null
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
memo: string
@Field(() => Date)
createdAt: Date
@ -83,24 +51,17 @@ export class Contribution {
@Field(() => Date)
contributionDate: Date
@Field(() => Int)
messagesCount: number
@Field(() => String)
status: string
@Field(() => Int, { nullable: true })
moderatorId: number | null
@Field(() => Date, { nullable: true })
resubmissionAt: Date | null
}
@ObjectType()
export class ContributionListResult {
constructor(count: number, list: Contribution[]) {
constructor(count: number, list: DbContribution[]) {
this.contributionCount = count
this.contributionList = list
this.contributionList = list.map(
(dbContribution: DbContribution) => new Contribution(dbContribution),
)
}
@Field(() => Int)

View File

@ -1,18 +1,19 @@
import { ContributionMessage as DbContributionMessage, User } from 'database'
import { ContributionMessage as DbContributionMessage } from 'database'
import { Field, Int, ObjectType } from 'type-graphql'
@ObjectType()
export class ContributionMessage {
constructor(contributionMessage: DbContributionMessage, user: User) {
this.id = contributionMessage.id
this.message = contributionMessage.message
this.createdAt = contributionMessage.createdAt
this.updatedAt = contributionMessage.updatedAt
this.type = contributionMessage.type
this.userFirstName = user.firstName
this.userLastName = user.lastName
this.userId = user.id
this.isModerator = contributionMessage.isModerator
constructor(dbContributionMessage: DbContributionMessage) {
const user = dbContributionMessage.user
this.id = dbContributionMessage.id
this.message = dbContributionMessage.message
this.createdAt = dbContributionMessage.createdAt
this.updatedAt = dbContributionMessage.updatedAt
this.type = dbContributionMessage.type
this.userFirstName = user?.firstName ?? null
this.userLastName = user?.lastName ?? null
this.userId = user?.id ?? null
this.isModerator = dbContributionMessage.isModerator
}
@Field(() => Int)

View File

@ -1,41 +1,45 @@
import { Contribution, User } from 'database'
import {
Contribution as DbContribution,
ContributionMessage as DbContributionMessage,
} from 'database'
import { Decimal } from 'decimal.js-light'
import { Field, Int, ObjectType } from 'type-graphql'
import { ContributionMessage } from './ContributionMessage'
import { User } from './User'
@ObjectType()
export class UnconfirmedContribution {
constructor(contribution: Contribution, user: User | undefined, creations: Decimal[]) {
this.id = contribution.id
this.userId = contribution.userId
this.amount = contribution.amount
this.memo = contribution.memo
this.date = contribution.contributionDate
this.firstName = user ? user.firstName : ''
this.lastName = user ? user.lastName : ''
this.email = user ? user.emailContact.email : ''
this.moderator = contribution.moderatorId
this.creation = creations
this.status = contribution.contributionStatus
this.messageCount = contribution.messages ? contribution.messages.length : 0
}
constructor(dbContribution: DbContribution) {
const user = dbContribution.user
this.id = dbContribution.id
this.userId = dbContribution.userId
this.amount = dbContribution.amount
this.memo = dbContribution.memo
this.contributionDate = dbContribution.contributionDate
this.user = user ? new User(user) : null
this.moderatorId = dbContribution.moderatorId
this.contributionStatus = dbContribution.contributionStatus
this.messagesCount = dbContribution.messages ? dbContribution.messages.length : 0
@Field(() => String)
firstName: string
this.messages = dbContribution.messages
? dbContribution.messages.map(
(dbMessage: DbContributionMessage) => new ContributionMessage(dbMessage),
)
: null
}
@Field(() => Int)
id: number
@Field(() => String)
lastName: string
@Field(() => Int, { nullable: true })
userId: number | null
@Field(() => Int)
userId: number
@Field(() => String)
email: string
@Field(() => User, { nullable: true })
user: User | null
@Field(() => Date)
date: Date
contributionDate: Date
@Field(() => String)
memo: string
@ -44,14 +48,14 @@ export class UnconfirmedContribution {
amount: Decimal
@Field(() => Int, { nullable: true })
moderator: number | null
@Field(() => [Decimal])
creation: Decimal[]
moderatorId: number | null
@Field(() => String)
status: string
contributionStatus: string
@Field(() => Int)
messageCount: number
messagesCount: number
@Field(() => [ContributionMessage], { nullable: true })
messages: ContributionMessage[] | null
}

View File

@ -85,7 +85,7 @@ describe('ContributionMessageResolver', () => {
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
contributionDate: new Date().toString(),
},
})
await mutate({
@ -138,7 +138,7 @@ describe('ContributionMessageResolver', () => {
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
contributionDate: new Date().toString(),
},
})
const mutationResult = await mutate({

View File

@ -77,7 +77,7 @@ export class ContributionMessageResolver {
{ id: contributionId } as DbContribution,
finalContributionMessage,
)
return new ContributionMessage(finalContributionMessage, user)
return new ContributionMessage(finalContributionMessage)
}
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES])
@ -89,16 +89,12 @@ export class ContributionMessageResolver {
): Promise<ContributionMessageListResult> {
const [contributionMessages, count] = await findContributionMessages({
contributionId,
currentPage,
pageSize,
order,
pagination: { currentPage, pageSize, order },
})
return {
count,
messages: contributionMessages.map(
(message) => new ContributionMessage(message, message.user),
),
messages: contributionMessages.map((message) => new ContributionMessage(message)),
}
}
@ -111,17 +107,13 @@ export class ContributionMessageResolver {
): Promise<ContributionMessageListResult> {
const [contributionMessages, count] = await findContributionMessages({
contributionId,
currentPage,
pageSize,
order,
pagination: { currentPage, pageSize, order },
showModeratorType: true,
})
return {
count,
messages: contributionMessages.map(
(message) => new ContributionMessage(message, message.user),
),
messages: contributionMessages.map((message) => new ContributionMessage(message)),
}
}
@ -196,6 +188,6 @@ export class ContributionMessageResolver {
finalContribution,
finalContributionMessage,
)
return new ContributionMessage(finalContributionMessage, moderator)
return new ContributionMessage(finalContributionMessage)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,19 +6,7 @@ import {
} from 'database'
import { Decimal } from 'decimal.js-light'
import { GraphQLResolveInfo } from 'graphql'
import {
Arg,
Args,
Authorized,
Ctx,
FieldResolver,
Info,
Int,
Mutation,
Query,
Resolver,
Root,
} from 'type-graphql'
import { Arg, Args, Authorized, Ctx, Info, Int, Mutation, Query, Resolver } from 'type-graphql'
import { EntityManager, IsNull, getConnection } from 'typeorm'
import { AdminCreateContributionArgs } from '@arg/AdminCreateContributionArgs'
@ -26,7 +14,6 @@ import { AdminUpdateContributionArgs } from '@arg/AdminUpdateContributionArgs'
import { ContributionArgs } from '@arg/ContributionArgs'
import { Paginated } from '@arg/Paginated'
import { SearchContributionsFilterArgs } from '@arg/SearchContributionsFilterArgs'
import { ContributionMessageType } from '@enum/ContributionMessageType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { ContributionType } from '@enum/ContributionType'
import { TransactionTypeId } from '@enum/TransactionTypeId'
@ -35,7 +22,6 @@ import { Contribution, ContributionListResult } from '@model/Contribution'
import { Decay } from '@model/Decay'
import { OpenCreation } from '@model/OpenCreation'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { User } from '@model/User'
import { RIGHTS } from '@/auth/RIGHTS'
import {
@ -62,9 +48,11 @@ import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { calculateDecay } from '@/util/decay'
import { fullName } from '@/util/utilities'
import { findContribution } from './util/contributions'
import { start } from 'repl'
import { ContributionMessageType } from '../enum/ContributionMessageType'
import { loadAllContributions, loadUserContributions } from './util/contributions'
import { getOpenCreations, getUserCreation, validateContribution } from './util/creations'
import { extractGraphQLFields, extractGraphQLFieldsForSelect } from './util/extractGraphQLFields'
import { extractGraphQLFields } from './util/extractGraphQLFields'
import { findContributions } from './util/findContributions'
import { getLastTransaction } from './util/getLastTransaction'
import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector'
@ -74,17 +62,17 @@ export class ContributionResolver {
@Authorized([RIGHTS.ADMIN_LIST_CONTRIBUTIONS])
@Query(() => Contribution)
async contribution(@Arg('id', () => Int) id: number): Promise<Contribution> {
const contribution = await findContribution(id)
if (!contribution) {
const dbContribution = await DbContribution.findOne({ where: { id } })
if (!dbContribution) {
throw new LogError('Contribution not found', id)
}
return new Contribution(contribution)
return new Contribution(dbContribution)
}
@Authorized([RIGHTS.CREATE_CONTRIBUTION])
@Mutation(() => UnconfirmedContribution)
async createContribution(
@Args() { amount, memo, creationDate }: ContributionArgs,
@Args() { amount, memo, contributionDate }: ContributionArgs,
@Ctx() context: Context,
): Promise<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
@ -92,14 +80,14 @@ export class ContributionResolver {
const user = getUser(context)
const creations = await getUserCreation(user.id, clientTimezoneOffset)
logger.trace('creations', creations)
const creationDateObj = new Date(creationDate)
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contributionDateObj = new Date(contributionDate)
validateContribution(creations, amount, contributionDateObj, clientTimezoneOffset)
const contribution = DbContribution.create()
contribution.userId = user.id
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.contributionDate = contributionDateObj
contribution.memo = memo
contribution.contributionType = ContributionType.USER
contribution.contributionStatus = ContributionStatus.PENDING
@ -108,7 +96,7 @@ export class ContributionResolver {
await DbContribution.save(contribution)
await EVENT_CONTRIBUTION_CREATE(user, contribution, amount)
return new UnconfirmedContribution(contribution, user, creations)
return new UnconfirmedContribution(contribution)
}
@Authorized([RIGHTS.DELETE_CONTRIBUTION])
@ -138,54 +126,55 @@ export class ContributionResolver {
const res = await contribution.softRemove()
return !!res
}
@Authorized([RIGHTS.LIST_CONTRIBUTIONS])
@Query(() => ContributionListResult)
async listContributions(
@Ctx() context: Context,
@Args()
paginated: Paginated,
@Arg('statusFilter', () => [ContributionStatus], { nullable: true })
statusFilter?: ContributionStatus[] | null,
@Arg('pagination') pagination: Paginated,
): Promise<ContributionListResult> {
const user = getUser(context)
const filter = new SearchContributionsFilterArgs()
filter.statusFilter = statusFilter
filter.userId = user.id
const [dbContributions, count] = await findContributions(paginated, filter, true, {
messages: true,
})
const [dbContributions, count] = await loadUserContributions(user.id, pagination)
return new ContributionListResult(
// show contributions in progress first
const inProgressContributions = dbContributions.filter(
(contribution) => contribution.contributionStatus === ContributionStatus.IN_PROGRESS,
)
const notInProgressContributions = dbContributions.filter(
(contribution) => contribution.contributionStatus !== ContributionStatus.IN_PROGRESS,
)
const result = new ContributionListResult(
count,
dbContributions.map((contribution) => {
[...inProgressContributions, ...notInProgressContributions].map((contribution) => {
// filter out moderator messages for this call
contribution.messages = contribution.messages?.filter(
(m) => (m.type as ContributionMessageType) !== ContributionMessageType.MODERATOR,
(message) =>
(message.type as ContributionMessageType) !== ContributionMessageType.MODERATOR,
)
return new Contribution(contribution, user)
return contribution
}),
)
return result
}
@Authorized([RIGHTS.LIST_CONTRIBUTIONS])
@Query(() => Int)
async countContributionsInProgress(@Ctx() context: Context): Promise<number> {
const user = getUser(context)
const count = await DbContribution.count({
select: { id: true },
where: { userId: user.id, contributionStatus: ContributionStatus.IN_PROGRESS },
})
return count
}
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTIONS])
@Query(() => ContributionListResult)
async listAllContributions(
@Args()
paginated: Paginated,
@Arg('statusFilter', () => [ContributionStatus], { nullable: true })
statusFilter?: ContributionStatus[] | null,
@Arg('pagination') pagination: Paginated,
): Promise<ContributionListResult> {
const filter = new SearchContributionsFilterArgs()
filter.statusFilter = statusFilter
const [dbContributions, count] = await findContributions(paginated, filter, false, {
user: true,
})
return new ContributionListResult(
count,
dbContributions.map((contribution) => new Contribution(contribution, contribution.user)),
)
const [dbContributions, count] = await loadAllContributions(pagination)
return new ContributionListResult(count, dbContributions)
}
@Authorized([RIGHTS.UPDATE_CONTRIBUTION])
@ -201,8 +190,7 @@ export class ContributionResolver {
contributionArgs,
context,
)
const { contribution, contributionMessage, availableCreationSums } =
await updateUnconfirmedContributionContext.run()
const { contribution, contributionMessage } = await updateUnconfirmedContributionContext.run()
await getConnection().transaction(async (transactionalEntityManager: EntityManager) => {
await transactionalEntityManager.save(contribution)
if (contributionMessage) {
@ -212,7 +200,7 @@ export class ContributionResolver {
const user = getUser(context)
await EVENT_CONTRIBUTION_UPDATE(user, contribution, contributionArgs.amount)
return new UnconfirmedContribution(contribution, user, availableCreationSums)
return new UnconfirmedContribution(contribution)
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION])
@ -370,10 +358,7 @@ export class ContributionResolver {
countOnly,
)
return new ContributionListResult(
count,
dbContributions.map((contribution) => new Contribution(contribution, contribution.user)),
)
return new ContributionListResult(count, dbContributions)
}
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
@ -612,21 +597,4 @@ export class ContributionResolver {
return !!res
}
// Field resolvers
@Authorized([RIGHTS.USER])
@FieldResolver(() => User)
async user(
@Root() contribution: DbContribution,
@Info() info: GraphQLResolveInfo,
): Promise<User> {
let user = contribution.user
if (!user) {
const queryBuilder = DbUser.createQueryBuilder('user')
queryBuilder.where('user.id = :userId', { userId: contribution.userId })
extractGraphQLFieldsForSelect(info, queryBuilder, 'user')
user = await queryBuilder.getOneOrFail()
}
return new User(user)
}
}

View File

@ -469,7 +469,7 @@ describe('TransactionLinkResolver', () => {
variables: {
amount: new Decimal(1000),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
contributionDate: now.toISOString(),
},
})
contribution = result.data.createContribution
@ -512,7 +512,7 @@ describe('TransactionLinkResolver', () => {
contributionId: contribution ? contribution.id : -1,
amount: new Decimal(800),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
contributionDate: now.toISOString(),
},
})
})

View File

@ -344,7 +344,7 @@ describe('send coins', () => {
// create contribution as user bob
const contribution = await mutate({
mutation: createContribution,
variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() },
variables: { amount: 1000, memo: 'testing', contributionDate: new Date().toISOString() },
})
// login as admin

View File

@ -458,7 +458,11 @@ describe('UserResolver', () => {
// create contribution as user bob
contribution = await mutate({
mutation: createContribution,
variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() },
variables: {
amount: 1000,
memo: 'testing',
contributionDate: new Date().toISOString(),
},
})
// login as admin

View File

@ -72,14 +72,14 @@ describe('semaphore', () => {
email: 'bibi@bloxberg.de',
amount: 1000,
memo: 'Herzlich Willkommen bei Gradido!',
creationDate: nMonthsBefore(new Date()),
contributionDate: nMonthsBefore(new Date()),
confirmed: true,
})
await creationFactory(testEnv, {
email: 'bob@baumeister.de',
amount: 1000,
memo: 'Herzlich Willkommen bei Gradido!',
creationDate: nMonthsBefore(new Date()),
contributionDate: nMonthsBefore(new Date()),
confirmed: true,
})
await mutate({
@ -121,7 +121,7 @@ describe('semaphore', () => {
} = await mutate({
mutation: createContribution,
variables: {
creationDate: contributionDateFormatter(new Date()),
contributionDate: contributionDateFormatter(new Date()),
amount: 200,
memo: 'Bobs Contribution',
},
@ -144,7 +144,7 @@ describe('semaphore', () => {
} = await mutate({
mutation: createContribution,
variables: {
creationDate: contributionDateFormatter(new Date()),
contributionDate: contributionDateFormatter(new Date()),
amount: 200,
memo: 'Bibis Contribution',
},

View File

@ -1,5 +1,93 @@
import { Contribution } from 'database'
import { Order } from '@/graphql/enum/Order'
import { Paginated } from '@arg/Paginated'
import { Contribution as DbContribution } from 'database'
import { FindManyOptions, In } from 'typeorm'
export const findContribution = async (id: number): Promise<Contribution | null> => {
return Contribution.findOne({ where: { id } })
// TODO: combine with Pagination class for all queries to use
function buildPaginationOptions(paginated: Paginated): FindManyOptions<DbContribution> {
const { currentPage, pageSize } = paginated
return {
skip: (currentPage - 1) * pageSize,
take: pageSize,
}
}
/*
* Load user contributions with messages
* @param userId if userId is set, load all contributions of the user, with messages
* @param paginated pagination, see {@link Paginated}
*/
export const loadUserContributions = async (
userId: number,
paginated: Paginated,
): Promise<[DbContribution[], number]> => {
const { order } = paginated
// manual, faster and simpler queries as auto generated from typeorm
const countPromise = DbContribution.count({
select: { id: true },
where: { userId },
withDeleted: true,
})
// we collect all contributions, ignoring if user exist or not
const contributionIds = await DbContribution.find({
select: { id: true },
where: { userId },
withDeleted: true,
order: { createdAt: order, id: order },
...buildPaginationOptions(paginated),
})
const contributionsPromise = DbContribution.find({
relations: { messages: { user: true } },
withDeleted: true,
order: { createdAt: order, id: order, messages: { createdAt: Order.ASC } },
where: { id: In(contributionIds.map((c) => c.id)) },
})
return [await contributionsPromise, await countPromise]
// original code
// note: typeorm will create similar queries as above, but more complex and slower
/*
return DbContribution.findAndCount({
where: { userId },
withDeleted: true,
relations: { messages: { user: true } },
order: { createdAt: order, id: order, messages: { createdAt: Order.ASC } },
...buildPaginationOptions(paginated),
})
*/
}
/*
* Load all contributions
* @param paginated pagination, see {@link Paginated}
*/
export const loadAllContributions = async (
paginated: Paginated,
): Promise<[DbContribution[], number]> => {
const { order } = paginated
// manual, faster queries as auto generated from typeorm
const countPromise = DbContribution.count({ select: { id: true } })
// console.log('loadAllContributions', { count })
const contributionIds = await DbContribution.find({
select: { id: true },
order: { createdAt: order, id: order },
...buildPaginationOptions(paginated),
})
const contributionsPromise = DbContribution.find({
relations: { user: { emailContact: true } },
order: { createdAt: order, id: order },
where: { id: In(contributionIds.map((c) => c.id)) },
})
return [await contributionsPromise, await countPromise]
// original code
// note: typeorm will create similar queries as above, but more complex and slower
/*
return DbContribution.findAndCount({
relations: { user: { emailContact: true } },
order: { createdAt: order, id: order },
...buildPaginationOptions(paginated),
})
*/
}

View File

@ -97,7 +97,7 @@ describe('util/creation', () => {
variables: {
amount: 400.0,
memo: 'Contribution for this month',
creationDate: contributionDateFormatter(now),
contributionDate: contributionDateFormatter(now),
},
})
await mutate({
@ -105,7 +105,7 @@ describe('util/creation', () => {
variables: {
amount: 500.0,
memo: 'Contribution for the last month',
creationDate: contributionDateFormatter(
contributionDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
),
},

View File

@ -1,28 +1,26 @@
import { ContributionMessage as DbContributionMessage } from 'database'
import { In } from 'typeorm'
import { Paginated } from '@arg/Paginated'
import { ContributionMessageType } from '@enum/ContributionMessageType'
import { Order } from '@enum/Order'
interface FindContributionMessagesOptions {
contributionId: number
pageSize: number
currentPage: number
order: Order
pagination: Paginated
showModeratorType?: boolean
}
export const findContributionMessages = async (
options: FindContributionMessagesOptions,
): Promise<[DbContributionMessage[], number]> => {
const { contributionId, pageSize, currentPage, order, showModeratorType } = options
const { contributionId, pagination, showModeratorType } = options
const messageTypes = [ContributionMessageType.DIALOG, ContributionMessageType.HISTORY]
if (showModeratorType) {
messageTypes.push(ContributionMessageType.MODERATOR)
}
const { currentPage, pageSize, order } = pagination
return DbContributionMessage.findAndCount({
where: {
contributionId,

View File

@ -17,14 +17,14 @@ export class UnconfirmedContributionUserRole extends AbstractUnconfirmedContribu
contribution: Contribution,
private updateData: ContributionArgs,
) {
super(contribution, updateData.amount, new Date(updateData.creationDate))
super(contribution, updateData.amount, new Date(updateData.contributionDate))
logger.debug('use UnconfirmedContributionUserRole')
}
protected update(): void {
this.self.amount = this.updateData.amount
this.self.memo = this.updateData.memo
this.self.contributionDate = new Date(this.updateData.creationDate)
this.self.contributionDate = new Date(this.updateData.contributionDate)
this.self.contributionStatus = ContributionStatus.PENDING
this.self.updatedAt = new Date()
// null because updated by user them self

View File

@ -2,7 +2,7 @@ export interface CreationInterface {
email: string
amount: number
memo: string
creationDate: string
contributionDate: string
confirmed?: boolean
// number of months to move the confirmed creation to the past
moveCreationDate?: number

View File

@ -122,7 +122,7 @@ bobsSendings.forEach((sending) => {
email: 'bob@baumeister.de',
amount: sending.amount,
memo: sending.memo,
creationDate: nMonthsBefore(new Date()),
contributionDate: nMonthsBefore(new Date()),
confirmed: true,
})
})
@ -132,7 +132,7 @@ export const creations: CreationInterface[] = [
email: 'bibi@bloxberg.de',
amount: 1000,
memo: 'Herzlich Willkommen bei Gradido!',
creationDate: nMonthsBefore(new Date()),
contributionDate: nMonthsBefore(new Date()),
confirmed: true,
moveCreationDate: 12,
},
@ -140,7 +140,7 @@ export const creations: CreationInterface[] = [
email: 'bibi@bloxberg.de',
amount: 1000,
memo: '#Hexen',
creationDate: nMonthsBefore(new Date()),
contributionDate: nMonthsBefore(new Date()),
confirmed: true,
moveCreationDate: 12,
},
@ -149,7 +149,7 @@ export const creations: CreationInterface[] = [
email: 'raeuber@hotzenplotz.de',
amount: 1000,
memo: 'Herzlich Willkommen bei Gradido!',
creationDate: nMonthsBefore(new Date()),
contributionDate: nMonthsBefore(new Date()),
confirmed: true,
},
]

View File

@ -33,7 +33,7 @@ export const creationFactory = async (
if (creation.moveCreationDate) {
const transaction = await Transaction.findOneOrFail({
where: { userId: user.id, creationDate: new Date(creation.creationDate) },
where: { userId: user.id, creationDate: new Date(creation.contributionDate) },
order: { balanceDate: 'DESC' },
})

View File

@ -265,8 +265,8 @@ export const deleteContributionLink = gql`
`
export const createContribution = gql`
mutation ($amount: Decimal!, $memo: String!, $creationDate: String!) {
createContribution(amount: $amount, memo: $memo, creationDate: $creationDate) {
mutation ($amount: Decimal!, $memo: String!, $contributionDate: String!) {
createContribution(amount: $amount, memo: $memo, contributionDate: $contributionDate) {
id
amount
memo
@ -276,12 +276,12 @@ export const createContribution = gql`
`
export const updateContribution = gql`
mutation ($contributionId: Int!, $amount: Decimal!, $memo: String!, $creationDate: String!) {
mutation ($contributionId: Int!, $amount: Decimal!, $memo: String!, $contributionDate: String!) {
updateContribution(
contributionId: $contributionId
amount: $amount
memo: $memo
creationDate: $creationDate
contributionDate: $contributionDate
) {
id
amount

View File

@ -244,52 +244,47 @@ export const queryTransactionLink = gql`
`
export const listContributions = gql`
query (
$currentPage: Int = 1
$pageSize: Int = 5
$order: Order
$statusFilter: [ContributionStatus!]
) {
listContributions(
currentPage: $currentPage
pageSize: $pageSize
order: $order
statusFilter: $statusFilter
) {
query ($pagination: Paginated!) {
listContributions(pagination: $pagination) {
contributionCount
contributionList {
id
amount
memo
createdAt
contributionDate
contributionStatus
messagesCount
createdAt
confirmedAt
confirmedBy
deletedAt
status
messagesCount
deniedAt
deniedBy
updatedBy
updatedAt
deletedAt
moderatorId
}
}
}
`
export const listAllContributions = `
query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC, $statusFilter: [ContributionStatus!]) {
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order, statusFilter: $statusFilter) {
query ($pagination: Paginated!) {
listAllContributions(pagination: $pagination) {
contributionCount
contributionList {
id
firstName
lastName
user {
firstName
lastName
}
amount
memo
createdAt
confirmedAt
confirmedBy
contributionDate
status
contributionStatus
messagesCount
deniedAt
deniedBy
@ -318,7 +313,7 @@ export const adminListContributions = gql`
confirmedAt
confirmedBy
contributionDate
status
contributionStatus
messagesCount
deniedAt
deniedBy

View File

@ -38,7 +38,8 @@ export class Connection {
synchronize: false,
logging: true,
logger: new FileLogger('all', {
logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
// workaround to let previous path working, because with esbuild the script root path has changed
logPath: (CONFIG.PRODUCTION ? '../' : '') + CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
}),
extra: {
charset: 'utf8mb4_unicode_ci',

View File

@ -6,9 +6,13 @@
"cache": false
},
"locales": {},
"locales:fix": {},
"lint": {
"dependsOn": ["locales", "database#build"]
},
"lint:fix": {
"dependsOn": ["locales:fix", "database#build"]
},
"typecheck": {
"dependsOn": ["database#build", "config-schema#build"]
},

View File

@ -107,8 +107,16 @@ exec > >(tee -a $UPDATE_HTML) 2>&1
# configure nginx for the update-page
echo 'Configuring nginx to serve the update-page'
nginx_restart() {
sudo /etc/init.d/nginx restart || {
echo -e "\e[33mwarn: nginx restart failed, will try to fix with 'sudo systemctl reset-failed nginx' and 'sudo systemctl start nginx'\e[0m" > /dev/tty
sudo systemctl reset-failed nginx
sudo systemctl start nginx
}
}
nginx_restart
ln -sf $SCRIPT_DIR/nginx/sites-available/update-page.conf $SCRIPT_DIR/nginx/sites-enabled/default
sudo /etc/init.d/nginx restart
# helper functions
log_step() {
@ -141,7 +149,7 @@ onError() {
log_error "( x.x ) Exit Code: $exit_code"
log_error " > < Offending command: '$BASH_COMMAND'"
log_error ""
exit 1
exit 1
}
trap onError ERR
@ -314,8 +322,7 @@ done
# let nginx showing gradido
log_step 'Configuring nginx to serve gradido again'
ln -sf $SCRIPT_DIR/nginx/sites-available/gradido.conf $SCRIPT_DIR/nginx/sites-enabled/default
sudo nginx -t
sudo /etc/init.d/nginx restart
nginx_restart
# keep the update log
cat $UPDATE_HTML >> $GRADIDO_LOG_PATH/update.$TODAY.log

View File

@ -1,10 +1,9 @@
import { CONFIG } from '@/config'
// TODO This is super weird - since the entities are defined in another project they have their own globals.
// We cannot use our connection here, but must use the external typeorm installation
import { entities } from 'database'
import { Connection, FileLogger, createConnection } from 'typeorm'
import { CONFIG } from '@/config'
export const connection = async (): Promise<Connection | null> => {
try {
return createConnection({
@ -19,7 +18,8 @@ export const connection = async (): Promise<Connection | null> => {
synchronize: false,
logging: true,
logger: new FileLogger('all', {
logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
// workaround to let previous path working, because with esbuild the script root path has changed
logPath: '../' + CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
}),
extra: {
charset: 'utf8mb4_unicode_ci',

View File

@ -18,7 +18,8 @@ const connection = async (): Promise<Connection | null> => {
synchronize: false,
logging: true,
logger: new FileLogger('all', {
logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
// workaround to let previous path working, because with esbuild the script root path has changed
logPath: '../' + CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
}),
extra: {
charset: 'utf8mb4_unicode_ci',

View File

@ -13,9 +13,9 @@ ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
ARG BUILD_VERSION
ENV BUILD_VERSION=${BUILD_VERSION:-'broken'}
ARG BUILD_COMMIT
ENV BUILD_COMMIT=${BUILD_COMMIT:-'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'}
ENV BUILD_COMMIT=${BUILD_COMMIT:-'decafcabdecafcabdecafcabdecafcabdecafcab'}
ARG BUILD_COMMIT_SHORT
ENV BUILD_COMMIT_SHORT=${BUILD_COMMIT_SHORT:-'deadbeef'}
ENV BUILD_COMMIT_SHORT=${BUILD_COMMIT_SHORT:-'decafcab'}
## SET NODE_ENV
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}

View File

@ -9,12 +9,15 @@
"postbuild": "uname | grep -q Linux && find build -type f -regex '.*\\.\\(html\\|js\\|css\\|svg\\|json\\)' -exec gzip -9 -k {} + || echo 'Skip precompress on non-Linux'",
"analyse-bundle": "yarn build && webpack-bundle-analyzer build/webpack.stats.json",
"lint": "eslint --max-warnings=0 --ext .js,.vue,.json .",
"lint:fix": "eslint --max-warnings=0 --ext .js,.vue,.json . --fix",
"stylelint": "stylelint --max-warnings=0 '**/*.{scss,vue}'",
"stylelint:fix": "stylelint --max-warnings=0 '**/*.{scss,vue}' --fix",
"test": "cross-env TZ=UTC vitest run",
"test:coverage": "cross-env TZ=UTC vitest run --coverage",
"test:debug": "cross-env TZ=UTC node --inspect-brk ./node_modules/vitest/vitest.mjs",
"test:watch": "cross-env TZ=UTC vitest",
"locales": "scripts/sort.sh",
"locales:fix": "scripts/sort.sh --fix",
"compile-scss": "node ./scripts/scss.mjs compile",
"watch-scss": "node ./scripts/scss.mjs watch",
"compile-scss-sass": "node ./scripts/scss.mjs compile sass",

View File

@ -52,7 +52,7 @@ describe('ContributionMessagesFormular', () => {
}
beforeEach(() => {
mockMutate.mockResolvedValue({})
mockMutate.mockResolvedValue({ data: { createContributionMessage: {} } })
wrapper = createWrapper()
})
@ -66,7 +66,7 @@ describe('ContributionMessagesFormular', () => {
it('resets the form on reset event', async () => {
await wrapper.find('form').trigger('reset')
expect(wrapper.vm.form.text).toBe('')
expect(wrapper.vm.formText).toBe('')
})
describe('form submission', () => {
@ -82,19 +82,14 @@ describe('ContributionMessagesFormular', () => {
})
})
it('emits get-list-contribution-messages event', async () => {
it('emits add-contribution-message event', async () => {
await nextTick()
expect(wrapper.emitted('get-list-contribution-messages')).toEqual([[false]])
})
it('emits update-status event', async () => {
await nextTick()
expect(wrapper.emitted('update-status')).toEqual([[42]])
expect(wrapper.emitted('add-contribution-message')).toEqual([[{}]])
})
it('resets the form text', async () => {
await nextTick()
expect(wrapper.vm.form.text).toBe('')
expect(wrapper.vm.formText).toBe('')
})
it('shows success toast', async () => {

View File

@ -3,13 +3,7 @@
<small class="ps-2 pt-3">{{ $t('form.reply') }}</small>
<div>
<BForm @submit.prevent="onSubmit" @reset="onReset">
<BFormTextarea
id="textarea"
:model-value="form.text"
:placeholder="$t('form.memo')"
:rows="3"
@update:model-value="form.text = $event"
/>
<BFormTextarea id="textarea" v-model="formText" :placeholder="$t('form.memo')" :rows="3" />
<BRow class="mt-4 mb-4">
<BCol>
<BButton type="reset" variant="secondary">{{ $t('form.cancel') }}</BButton>
@ -39,35 +33,31 @@ const props = defineProps({
},
})
const emit = defineEmits(['get-list-contribution-messages', 'update-status'])
const emit = defineEmits(['add-contribution-message'])
const { t } = useI18n()
const { toastSuccess, toastError } = useAppToast()
const { mutate: createContributionMessageMutation } = useMutation(createContributionMessage)
const form = ref({
text: '',
})
const formText = ref('')
const isSubmitting = ref(false)
const disabled = computed(() => {
return form.value.text === '' || isSubmitting.value
return formText.value === '' || isSubmitting.value
})
async function onSubmit() {
isSubmitting.value = true
try {
await createContributionMessageMutation({
const result = await createContributionMessageMutation({
contributionId: props.contributionId,
message: form.value.text,
message: formText.value,
})
emit('get-list-contribution-messages', false)
emit('update-status', props.contributionId)
form.value.text = ''
formText.value = ''
emit('add-contribution-message', result.data.createContributionMessage)
toastSuccess(t('message.reply'))
} catch (error) {
toastError(error.message)
@ -77,6 +67,6 @@ async function onSubmit() {
}
function onReset() {
form.value.text = ''
formText.value = ''
}
</script>

View File

@ -77,16 +77,4 @@ describe('ContributionMessagesList', () => {
expect(wrapper.find('button').text()).toContain('form.close')
})
})
describe('events', () => {
beforeEach(() => {
wrapper = createWrapper()
})
it('emits update-status event when updateStatus method is called', async () => {
await wrapper.vm.updateStatus(42)
expect(wrapper.emitted('update-status')).toBeTruthy()
expect(wrapper.emitted('update-status')[0]).toEqual([42])
})
})
})

View File

@ -10,12 +10,11 @@
v-if="['PENDING', 'IN_PROGRESS'].includes(status)"
:contribution-id="contributionId"
v-bind="$attrs"
@update-status="updateStatus"
/>
</div>
<div v-b-toggle="'collapse' + String(contributionId)" class="text-center pointer clearboth">
<BButton variant="outline-primary" block class="mb-3">
<div class="text-center pointer clearboth">
<BButton variant="outline-primary" block @click="$emit('close-messages-list')">
<IBiArrowUpShort />
{{ $t('form.close') }}
</BButton>
@ -46,11 +45,6 @@ export default {
required: true,
},
},
methods: {
updateStatus(id) {
this.$emit('update-status', id)
},
},
}
</script>
<style scoped>

View File

@ -38,12 +38,6 @@
<parse-message v-bind="message" data-test="message"></parse-message>
</BCol>
<BCol cols="2">
<!-- <avatar-->
<!-- class="vue3-avatar"-->
<!-- :name="storeName.username"-->
<!-- :initials="storeName.initials"-->
<!-- :border="false"-->
<!-- />-->
<app-avatar
class="vue3-avatar"
:name="storeName.username"
@ -55,12 +49,6 @@
<div v-else>
<BRow class="mb-3 p-2 is-moderator">
<BCol cols="2">
<!-- <avatar-->
<!-- class="vue3-avatar"-->
<!-- :name="moderationName.username"-->
<!-- :initials="moderationName.initials"-->
<!-- :border="false"-->
<!-- />-->
<app-avatar :name="moderationName.username" :initials="moderationName.initials" />
</BCol>
<BCol cols="10">
@ -70,7 +58,6 @@
{{ $t('community.moderator') }}
</span>
</div>
<div class="small" data-test="date">{{ $d(new Date(message.createdAt), 'short') }}</div>
<parse-message v-bind="message" data-test="message"></parse-message>
</BCol>

View File

@ -0,0 +1,71 @@
<template>
<transition name="fade-out" @after-leave="resetForm">
<div v-if="showForm">
<contribution-form
v-if="maxForMonths"
:model-value="form"
:max-gdd-last-month="parseFloat(maxForMonths.openCreations[1].amount)"
:max-gdd-this-month="parseFloat(maxForMonths.openCreations[2].amount)"
@upsert-contribution="handleCreateContribution"
/>
</div>
</transition>
</template>
<script setup>
import ContributionForm from '@/components/Contributions/ContributionForm.vue'
import { GDD_PER_HOUR } from '@/constants'
import { useQuery, useMutation } from '@vue/apollo-composable'
import { openCreationsAmounts, createContribution } from '@/graphql/contributions.graphql'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
const { toastError, toastSuccess } = useAppToast()
const { t } = useI18n()
const { result: maxForMonths, refetch } = useQuery(
openCreationsAmounts,
{},
{ fetchPolicy: 'no-cache' },
)
const { mutate: createContributionMutation } = useMutation(createContribution)
const form = ref(emptyForm())
const showForm = ref(true)
function emptyForm() {
return {
contributionDate: undefined,
memo: '',
hours: '',
amount: GDD_PER_HOUR,
}
}
async function handleCreateContribution(contribution) {
try {
await createContributionMutation({ ...contribution })
toastSuccess(t('contribution.submitted'))
showForm.value = false
} catch (err) {
toastError(err.message)
}
}
function resetForm() {
refetch()
showForm.value = true
}
</script>
<style scoped>
.fade-out-enter-active,
.fade-out-leave-active {
transition: opacity 0.5s ease;
}
.fade-out-enter-from,
.fade-out-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<contribution-form
:model-value="modelValue"
:max-gdd-last-month="maxForMonths[0]"
:max-gdd-this-month="maxForMonths[1]"
@upsert-contribution="handleUpdateContribution"
@abort="emit('contribution-updated')"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import ContributionForm from '@/components/Contributions/ContributionForm.vue'
import { useQuery, useMutation } from '@vue/apollo-composable'
import { openCreations, updateContribution } from '@/graphql/contributions.graphql'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
const { toastError, toastSuccess } = useAppToast()
const { t } = useI18n()
const emit = defineEmits(['contribution-updated'])
const props = defineProps({
modelValue: { type: Object, required: true },
})
const { result: openCreationsResult } = useQuery(openCreations, {}, { fetchPolicy: 'no-cache' })
const { mutate: updateContributionMutation } = useMutation(updateContribution)
const maxForMonths = computed(() => {
const originalDate = new Date(props.modelValue.contributionDate)
if (openCreationsResult.value && openCreationsResult.value.openCreations.length) {
return openCreationsResult.value.openCreations.slice(1).map((creation) => {
if (
creation.year === originalDate.getFullYear() &&
creation.month === originalDate.getMonth()
) {
return parseFloat(creation.amount) + parseFloat(props.modelValue.amount)
}
return parseFloat(creation.amount)
})
}
return [0, 0]
})
async function handleUpdateContribution(contribution) {
try {
await updateContributionMutation({ contributionId: props.modelValue.id, ...contribution })
toastSuccess(t('contribution.updated'))
emit('contribution-updated')
} catch (err) {
toastError(err.message)
}
}
</script>

View File

@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ContributionForm from './ContributionForm.vue'
import { useForm } from 'vee-validate'
// Mock external components and dependencies
vi.mock('@/components/Inputs/InputAmount', () => ({
@ -37,12 +36,16 @@ vi.mock('vee-validate', () => ({
})),
}))
const global = {
stubs: ['BForm', 'BFormInput', 'BRow', 'BCol', 'BButton'],
}
describe('ContributionForm', () => {
let wrapper
const defaultProps = {
modelValue: {
date: '2024-09-12',
contributionDate: '2024-09-12',
memo: 'Test memo',
hours: 2,
amount: 40,
@ -51,12 +54,26 @@ describe('ContributionForm', () => {
maxGddThisMonth: 200,
}
const createWrapperWithDate = (date) => {
return mount(ContributionForm, {
props: {
...defaultProps,
modelValue: {
...defaultProps.modelValue,
contributionDate: date.toISOString(),
},
},
global,
})
}
const thisMonth = new Date()
const lastMonth = new Date()
lastMonth.setMonth(lastMonth.getMonth() - 1, 1)
beforeEach(() => {
wrapper = mount(ContributionForm, {
props: defaultProps,
global: {
stubs: ['BForm', 'BFormInput', 'BRow', 'BCol', 'BButton'],
},
global,
})
})
@ -65,19 +82,17 @@ describe('ContributionForm', () => {
})
describe('compute isThisMonth', () => {
it('return true', async () => {
await wrapper.setProps({
modelValue: { date: new Date().toISOString() },
})
expect(wrapper.vm.isThisMonth).toBe(true)
})
it('return false', async () => {
const now = new Date()
const lastMonth = new Date(now.setMonth(now.getMonth() - 1, 1))
await wrapper.setProps({
modelValue: { date: lastMonth.toISOString() },
})
expect(wrapper.vm.isThisMonth).toBe(false)
it.each([
[thisMonth, true, 'should return true for current month'],
[lastMonth, false, 'should return false for last month'],
[
new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
false,
'should return false for next year',
],
])('%s => %s (%s)', (date, expected, desc) => {
const wrapper = createWrapperWithDate(date)
expect(wrapper.vm.isThisMonth).toBe(expected)
})
})
@ -85,21 +100,39 @@ describe('ContributionForm', () => {
it('if both max gdd are > 0', () => {
expect(wrapper.vm.noOpenCreation).toBeUndefined()
})
it('if max gdd for this month is 0, and form.date is in last month', async () => {
const now = new Date()
const lastMonth = new Date(now.setMonth(now.getMonth() - 1, 1))
await wrapper.setProps({
maxGddThisMonth: 0,
modelValue: { date: lastMonth.toISOString() },
describe('if form.date is in last month', () => {
beforeEach(() => {
wrapper = createWrapperWithDate(lastMonth)
})
it('if max gdd for this month is 0', async () => {
await wrapper.setProps({
maxGddThisMonth: 0,
})
expect(wrapper.vm.noOpenCreation).toBeUndefined()
})
it('if max gdd last month is 0', async () => {
await wrapper.setProps({
maxGddLastMonth: 0,
})
expect(wrapper.vm.noOpenCreation).toBe('contribution.noOpenCreation.lastMonth')
})
expect(wrapper.vm.noOpenCreation).toBeUndefined()
})
it('if max gdd for last month is 0, and form.date is in this month', async () => {
await wrapper.setProps({
maxGddLastMonth: 0,
modelValue: { date: new Date().toISOString() },
describe('if form.date is in this month', () => {
beforeEach(() => {
wrapper = createWrapperWithDate(thisMonth)
})
it('if max gdd for last month is 0', async () => {
await wrapper.setProps({
maxGddLastMonth: 0,
})
expect(wrapper.vm.noOpenCreation).toBeUndefined()
})
it('if max gdd this month is 0', async () => {
await wrapper.setProps({
maxGddThisMonth: 0,
})
expect(wrapper.vm.noOpenCreation).toBe('contribution.noOpenCreation.thisMonth')
})
expect(wrapper.vm.noOpenCreation).toBeUndefined()
})
it('if max gdd is 0 for both months', async () => {
await wrapper.setProps({
@ -108,30 +141,12 @@ describe('ContributionForm', () => {
})
expect(wrapper.vm.noOpenCreation).toBe('contribution.noOpenCreation.allMonth')
})
it('if max gdd this month is zero and form.date is inside this month', async () => {
await wrapper.setProps({
maxGddThisMonth: 0,
modelValue: { date: new Date().toISOString() },
})
expect(wrapper.vm.noOpenCreation).toBe('contribution.noOpenCreation.thisMonth')
})
it('if max gdd last month is zero and form.date is inside last month', async () => {
const now = new Date()
const lastMonth = new Date(now.setMonth(now.getMonth() - 1, 1))
await wrapper.setProps({
maxGddLastMonth: 0,
modelValue: { date: lastMonth.toISOString() },
})
expect(wrapper.vm.noOpenCreation).toBe('contribution.noOpenCreation.lastMonth')
})
})
it('computes disabled correctly', async () => {
expect(wrapper.vm.disabled).toBe(true)
await wrapper.setProps({
modelValue: { date: new Date().toISOString().slice(0, 10) },
})
wrapper = createWrapperWithDate(thisMonth)
wrapper.vm.form.amount = 100
@ -154,7 +169,7 @@ describe('ContributionForm', () => {
expect(wrapper.vm.form.amount).toBe('60.00')
})
it('emits update-contribution event on submit for existing contribution', async () => {
it('emits upsert-contribution event on submit for existing contribution', async () => {
const existingContribution = {
...defaultProps.modelValue,
id: '123',
@ -174,17 +189,17 @@ describe('ContributionForm', () => {
wrapper.vm.submit()
expect(wrapper.emitted('update-contribution')).toBeTruthy()
expect(wrapper.emitted('update-contribution')[0][0]).toEqual(
expect(wrapper.emitted('upsert-contribution')).toBeTruthy()
expect(wrapper.emitted('upsert-contribution')[0][0]).toEqual(
expect.objectContaining({
id: '123',
}),
)
})
it('emits set-contribution event on submit for new contribution', async () => {
it('emits upsert-contribution event on submit for new contribution', async () => {
wrapper.vm.submit()
expect(wrapper.emitted('set-contribution')).toBeTruthy()
expect(wrapper.emitted('upsert-contribution')).toBeTruthy()
})
})

View File

@ -1,4 +1,10 @@
<template>
<open-creations-amount
:minimal-date="minimalDate"
:max-gdd-last-month="maxGddLastMonth"
:max-gdd-this-month="maxGddThisMonth"
/>
<div class="mb-3"></div>
<div class="contribution-form">
<BForm
class="form-style p-3 bg-white app-box-shadow gradido-border-radius"
@ -6,13 +12,13 @@
>
<ValidatedInput
id="contribution-date"
:model-value="date"
name="date"
:model-value="form.contributionDate"
name="contributionDate"
:label="$t('contribution.selectDate')"
:no-flip="true"
class="mb-4 bg-248"
type="date"
:rules="validationSchema.fields.date"
:rules="validationSchema.fields.contributionDate"
@update:model-value="updateField"
/>
<div v-if="noOpenCreation" class="p-3" data-test="contribution-message">
@ -21,7 +27,7 @@
<div v-else>
<ValidatedInput
id="contribution-memo"
:model-value="memo"
:model-value="form.memo"
name="memo"
:label="$t('contribution.activity')"
:placeholder="$t('contribution.yourActivity')"
@ -31,7 +37,7 @@
/>
<ValidatedInput
name="hours"
:model-value="hours"
:model-value="form.hours"
:label="$t('form.hours')"
placeholder="0.01"
step="0.01"
@ -41,7 +47,7 @@
/>
<LabeledInput
id="contribution-amount"
:model-value="amount"
:model-value="form.amount"
class="mt-3"
name="amount"
:label="$t('form.amount')"
@ -57,7 +63,7 @@
type="reset"
variant="secondary"
data-test="button-cancel"
@click="fullFormReset"
@click="emit('abort')"
>
{{ $t('form.cancel') }}
</BButton>
@ -78,45 +84,56 @@
</BForm>
</div>
</template>
<script setup>
import { reactive, computed, watch } from 'vue'
import { reactive, computed, ref, onMounted, onUnmounted, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import ValidatedInput from '@/components/Inputs/ValidatedInput'
import LabeledInput from '@/components/Inputs/LabeledInput'
import { memo as memoSchema } from '@/validationSchemas'
import OpenCreationsAmount from './OpenCreationsAmount.vue'
import { object, date as dateSchema, number } from 'yup'
import { GDD_PER_HOUR } from '../../constants'
const amountToHours = (amount) => parseFloat(amount / GDD_PER_HOUR).toFixed(2)
const hoursToAmount = (hours) => parseFloat(hours * GDD_PER_HOUR).toFixed(2)
const props = defineProps({
modelValue: { type: Object, required: true },
maxGddLastMonth: { type: Number, required: true },
maxGddThisMonth: { type: Number, required: true },
})
const emit = defineEmits(['update-contribution', 'set-contribution', 'update:modelValue'])
const emit = defineEmits(['upsert-contribution', 'abort'])
const { t } = useI18n()
const form = reactive({ ...props.modelValue })
const entityDataToForm = computed(() => ({
...props.modelValue,
hours:
props.modelValue.hours !== undefined
? props.modelValue.hours
: amountToHours(props.modelValue.amount),
contributionDate: props.modelValue.contributionDate
? new Date(props.modelValue.contributionDate).toISOString().slice(0, 10)
: undefined,
}))
// update local form if in parent form changed, it is necessary because the community page will reuse this form also for editing existing
// contributions, and it will reusing a existing instance of this component
watch(
() => props.modelValue,
(newValue) => Object.assign(form, newValue),
)
const form = reactive({ ...entityDataToForm.value })
// use computed to make sure child input update if props from parent from this component change
const amount = computed(() => form.amount)
const date = computed(() => form.date)
const hours = computed(() => form.hours)
const memo = computed(() => form.memo)
const now = ref(new Date()) // checked every minute, updated if day, month or year changed
const isThisMonth = computed(() => {
const formDate = new Date(form.date)
const now = new Date()
return formDate.getMonth() === now.getMonth() && formDate.getFullYear() === now.getFullYear()
const formContributionDate = new Date(form.contributionDate)
return (
formContributionDate.getMonth() === now.value.getMonth() &&
formContributionDate.getFullYear() === now.value.getFullYear()
)
})
const minimalDate = computed(() => {
const minimalDate = new Date(now.value)
minimalDate.setMonth(now.value.getMonth() - 1, 1)
return minimalDate
})
// reactive validation schema, because some boundaries depend on form input and existing data
@ -129,11 +146,10 @@ const validationSchema = computed(() => {
return object({
// The date field is required and needs to be a valid date
// contribution date
date: dateSchema()
contributionDate: dateSchema()
.required()
.min(new Date(new Date().setMonth(new Date().getMonth() - 1, 1)).toISOString().slice(0, 10)) // min date is first day of last month
.max(new Date().toISOString().slice(0, 10))
.default(''), // date cannot be in the future
.min(minimalDate.value.toISOString().slice(0, 10)) // min date is first day of last month
.max(now.value.toISOString().slice(0, 10)), // date cannot be in the future
memo: memoSchema,
hours: number()
.required()
@ -150,11 +166,12 @@ const validationSchema = computed(() => {
const disabled = computed(() => !validationSchema.value.isValidSync(form))
// decide message if no open creation exists
const noOpenCreation = computed(() => {
if (props.maxGddThisMonth <= 0 && props.maxGddLastMonth <= 0) {
return t('contribution.noOpenCreation.allMonth')
}
if (form.date) {
if (form.contributionDate) {
if (isThisMonth.value && props.maxGddThisMonth <= 0) {
return t('contribution.noOpenCreation.thisMonth')
}
@ -165,36 +182,36 @@ const noOpenCreation = computed(() => {
return undefined
})
// make sure, that base date for min and max date is up to date, even if user work at midnight
onMounted(() => {
const interval = setInterval(() => {
const localNow = new Date()
if (
localNow.getDate() !== now.value.getDate() ||
localNow.getMonth() !== now.value.getMonth() ||
localNow.getFullYear() !== now.value.getFullYear()
) {
now.value = localNow
}
}, 60 * 1000) // check every minute
onUnmounted(() => {
clearInterval(interval)
})
})
const updateField = (newValue, name) => {
if (typeof name === 'string' && name.length) {
form[name] = newValue
if (name === 'hours') {
const amount = form.hours ? (form.hours * GDD_PER_HOUR).toFixed(2) : GDD_PER_HOUR
const amount = form.hours ? hoursToAmount(form.hours) : GDD_PER_HOUR
form.amount = amount.toString()
}
}
emit('update:modelValue', form)
}
function submit() {
const dataToSave = { ...form }
let emitOption = 'set-contribution'
if (props.modelValue.id) {
dataToSave.id = props.modelValue.id
emitOption = 'update-contribution'
}
emit(emitOption, dataToSave)
fullFormReset()
}
function fullFormReset() {
emit('update:modelValue', {
id: undefined,
date: null,
memo: '',
hours: '',
amount: undefined,
})
emit('upsert-contribution', toRaw(form))
}
</script>
<style>

View File

@ -1,11 +1,40 @@
import { useQuery } from '@vue/apollo-composable'
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import ContributionList from './ContributionList'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ContributionList from './ContributionList'
import { createRouter, createWebHistory } from 'vue-router'
import { ref } from 'vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
contribution: {
noContributions: {
myContributions: 'No contributions',
emptyPage: 'No contributions',
},
},
},
},
})
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: { template: '<div>Home</div>' },
},
{
path: '/test',
name: 'test',
component: ContributionList,
},
],
})
vi.mock('@/components/Contributions/ContributionListItem.vue', () => ({
@ -15,11 +44,15 @@ vi.mock('@/components/Contributions/ContributionListItem.vue', () => ({
},
}))
vi.mock('@vue/apollo-composable', () => ({
useQuery: vi.fn(),
}))
describe('ContributionList', () => {
let wrapper
const global = {
plugins: [i18n],
plugins: [i18n, router],
mocks: {
$filters: {
GDD: vi.fn((val) => val),
@ -30,47 +63,69 @@ describe('ContributionList', () => {
},
}
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',
status: 'IN_PROGRESS',
},
{
id: 1,
date: '06/22/2022',
memo: 'Ich habe 30 Stunden Frau Müller beim EInkaufen und im Haushalt geholfen.',
amount: '600',
status: 'CONFIRMED',
},
{
id: 2,
date: '05/04/2022',
memo: 'Ich habe 50 Stunden den Nachbarkindern bei ihren Hausaufgaben geholfen und Nachhilfeunterricht gegeben.',
amount: '1000',
status: 'DENIED',
},
],
}
const contributions = ref({
listContributions: {
contributionCount: 3,
contributionList: [
{
id: 0,
date: '07/06/2022',
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
amount: '200',
status: 'IN_PROGRESS',
},
{
id: 1,
date: '06/22/2022',
memo: 'Ich habe 30 Stunden Frau Müller beim Einkaufen und im Haushalt geholfen.',
amount: '600',
status: 'CONFIRMED',
},
{
id: 2,
date: '05/04/2022',
memo: 'Ich habe 50 Stunden den Nachbarkindern bei ihren Hausaufgaben geholfen und Nachhilfeunterricht gegeben.',
amount: '1000',
status: 'DENIED',
},
],
},
})
const mountWrapper = () => {
return mount(ContributionList, {
props: propsData,
global,
})
}
const loading = ref(false)
describe('mount', () => {
const mockListContributionsQuery = vi.fn()
beforeEach(() => {
vi.mocked(useQuery).mockImplementation((query) => {
return {
result: contributions,
loading,
onResult: mockListContributionsQuery,
refetch: vi.fn(),
}
})
wrapper = mountWrapper()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('mount as user contributions list', () => {
it('fetches initial data', () => {
expect(mockListContributionsQuery).toHaveBeenCalled()
})
})
it('has a DIV .contribution-list', () => {
expect(wrapper.find('div.contribution-list').exists()).toBe(true)
})
@ -84,7 +139,7 @@ describe('ContributionList', () => {
describe('list count greater than page size', () => {
beforeEach(async () => {
await wrapper.setProps({ contributionCount: 33 })
contributions.value.listContributions.contributionCount = 33
})
it('has pagination buttons', () => {
@ -97,17 +152,17 @@ describe('ContributionList', () => {
window.scrollTo = scrollToMock
beforeEach(async () => {
await wrapper.setProps({ contributionCount: 33 })
await wrapper.findComponent({ name: 'BPagination' }).vm.$emit('update:modelValue', 2)
contributions.value.listContributions.contributionCount = 33
await wrapper
.findComponent({ name: 'PaginatorRouteParamsPage' })
.vm.$emit('update:modelValue', 2)
})
it('emits update contribution list', () => {
expect(wrapper.emitted('update-list-contributions')).toEqual([
[{ currentPage: 2, pageSize: 25 }],
])
it('updates current page', () => {
expect(wrapper.vm.currentPage).toBe(2)
})
it('scrolls to top', () => {
it.skip('scrolls to top', () => {
expect(scrollToMock).toHaveBeenCalledWith(0, 0)
})
})
@ -121,31 +176,7 @@ describe('ContributionList', () => {
})
it('emits update contribution form', () => {
expect(wrapper.emitted('update-contribution-form')).toEqual([['item']])
})
})
describe('delete contribution', () => {
beforeEach(async () => {
await wrapper
.findComponent({ name: 'ContributionListItem' })
.vm.$emit('delete-contribution', { id: 2 })
})
it('emits delete contribution', () => {
expect(wrapper.emitted('delete-contribution')).toEqual([[{ id: 2 }]])
})
})
describe('update status', () => {
beforeEach(async () => {
await wrapper
.findComponent({ name: 'ContributionListItem' })
.vm.$emit('update-status', { id: 2 })
})
it('emits update status', () => {
expect(wrapper.emitted('update-status')).toEqual([[{ id: 2 }]])
expect(wrapper.emitted('update-contribution-form')).toEqual([[{ item: 'item', page: 1 }]])
})
})
})

View File

@ -1,107 +1,107 @@
<template>
<div class="contribution-list">
<div v-for="item in items" :key="item.id + 'a'" class="mb-3">
<contribution-list-item
v-if="item.status === 'IN_PROGRESS'"
v-bind="item"
:contribution-id="item.id"
:all-contribution="allContribution"
@close-all-open-collapse="$emit('close-all-open-collapse')"
@update-contribution-form="updateContributionForm"
@delete-contribution="deleteContribution"
@update-status="updateStatus"
/>
<div v-if="items.length === 0 && !loading">
<div v-if="currentPage === 1">
{{ t('contribution.noContributions.myContributions') }}
</div>
<div v-for="item2 in items" :key="item2.id" class="mb-3">
<contribution-list-item
v-if="item2.status !== 'IN_PROGRESS'"
v-bind="item2"
:contribution-id="item2.id"
:all-contribution="allContribution"
@close-all-open-collapse="$emit('close-all-open-collapse')"
@update-contribution-form="updateContributionForm"
@delete-contribution="deleteContribution"
@update-status="updateStatus"
/>
<div v-else>
{{ t('contribution.noContributions.emptyPage') }}
</div>
<BPagination
v-if="isPaginationVisible"
:model-value="currentPage"
class="mt-3"
pills
size="lg"
:per-page="pageSize"
:total-rows="contributionCount"
align="center"
:hide-ellipsis="true"
@update:model-value="currentPage = $event"
/>
</div>
<div v-else class="contribution-list">
<div v-for="item in items" :key="item.id + 'a'" class="mb-3">
<div :id="`contributionListItem-${item.id}`">
<contribution-list-item
v-bind="item"
:contribution-id="item.id"
:messages-visible="openMessagesListId === item.id"
@toggle-messages-visible="toggleMessagesVisible(item.id)"
@update-contribution-form="updateContributionForm"
@contribution-changed="refetch()"
/>
</div>
</div>
</div>
<paginator-route-params-page
v-model="currentPage"
:page-size="pageSize"
:total-count="contributionCount"
:loading="loading"
/>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, nextTick } from 'vue'
import ContributionListItem from '@/components/Contributions/ContributionListItem.vue'
import { listContributions } from '@/graphql/contributions.graphql'
import { useQuery } from '@vue/apollo-composable'
import { PAGE_SIZE } from '@/constants'
import { useI18n } from 'vue-i18n'
import CONFIG from '@/config'
import { useRoute } from 'vue-router'
import PaginatorRouteParamsPage from '@/components/PaginatorRouteParamsPage.vue'
const props = defineProps({
items: {
type: Array,
required: true,
},
contributionCount: {
type: Number,
required: true,
},
showPagination: {
type: Boolean,
required: true,
},
pageSize: {
type: Number,
default: 25,
},
allContribution: {
type: Boolean,
required: false,
default: false,
const route = useRoute()
// composables
const { t } = useI18n()
// constants
const pageSize = PAGE_SIZE
const pollInterval = CONFIG.AUTO_POLL_INTERVAL || undefined
// events
const emit = defineEmits(['update-contribution-form'])
// refs
const currentPage = ref(Number(route.params.page) || 1)
const openMessagesListId = ref(null)
// queries
const { result, loading, refetch, onResult } = useQuery(
listContributions,
() => ({
pagination: {
currentPage: currentPage.value,
pageSize,
order: 'DESC',
},
}),
{
fetchPolicy: 'cache-and-network',
pollInterval,
},
)
// computed
const contributionCount = computed(() => {
return result.value?.listContributions.contributionCount || 0
})
const items = computed(() => {
return [...(result.value?.listContributions.contributionList || [])]
})
const emit = defineEmits([
'close-all-open-collapse',
'update-list-contributions',
'update-contribution-form',
'delete-contribution',
'update-status',
])
const currentPage = ref(1)
const messages = ref([])
const isPaginationVisible = computed(() => {
return props.showPagination && props.pageSize < props.contributionCount
})
watch(currentPage, () => {
updateListContributions()
})
const updateListContributions = () => {
emit('update-list-contributions', {
currentPage: currentPage.value,
pageSize: props.pageSize,
// callbacks
// scroll to anchor, if hash ist present in url and after data where loaded
onResult(({ _data }) => {
nextTick(() => {
if (!route.hash) {
return
}
const el = document.querySelector(route.hash)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
window.scrollTo(0, 0)
}
})
// methods
const toggleMessagesVisible = (contributionId) => {
if (openMessagesListId.value === contributionId) {
openMessagesListId.value = 0
} else {
openMessagesListId.value = contributionId
}
}
const updateContributionForm = (item) => {
emit('update-contribution-form', item)
}
const deleteContribution = (item) => {
emit('delete-contribution', item)
}
const updateStatus = (id) => {
emit('update-status', id)
emit('update-contribution-form', { item, page: currentPage.value })
}
</script>

View File

@ -0,0 +1,158 @@
import { listAllContributions } from '@/graphql/contributions.graphql'
import { useQuery } from '@vue/apollo-composable'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ContributionListAll from './ContributionListAll.vue'
import { createRouter, createWebHistory } from 'vue-router'
import { ref } from 'vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
})
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: { template: '<div>Home</div>' },
},
{
path: '/test',
name: 'test',
component: ContributionListAll,
},
],
})
vi.mock('@/components/Contributions/ContributionListAllItem.vue', () => ({
default: {
name: 'ContributionListAllItem',
template: '<div></div>',
},
}))
vi.mock('@vue/apollo-composable', () => ({
useQuery: vi.fn(),
}))
describe('ContributionListAll', () => {
let wrapper
const global = {
plugins: [i18n, router],
mocks: {
$filters: {
GDD: vi.fn((val) => val),
},
},
stubs: {
BPagination: true,
},
}
const allContributions = ref({
listAllContributions: {
contributionCount: 3,
contributionList: [
{
id: 0,
date: '07/06/2022',
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
amount: '200',
status: 'IN_PROGRESS',
},
{
id: 1,
date: '06/22/2022',
memo: 'Ich habe 30 Stunden Frau Müller beim Einkaufen und im Haushalt geholfen.',
amount: '600',
status: 'CONFIRMED',
},
{
id: 2,
date: '05/04/2022',
memo: 'Ich habe 50 Stunden den Nachbarkindern bei ihren Hausaufgaben geholfen und Nachhilfeunterricht gegeben.',
amount: '1000',
status: 'DENIED',
},
],
},
})
const mountWrapper = () => {
return mount(ContributionListAll, {
global,
})
}
const loading = ref(false)
describe('mount', () => {
beforeEach(() => {
vi.mocked(useQuery).mockImplementation((query) => {
return {
result: allContributions,
loading,
}
})
wrapper = mountWrapper()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('mount as all contributions list', () => {
it('fetches initial data', () => {
expect(useQuery).toHaveBeenCalled()
})
})
it('has a DIV .contribution-list-all', () => {
expect(wrapper.find('div.contribution-list-all').exists()).toBe(true)
})
describe('pagination', () => {
describe('list count smaller than page size', () => {
it('has no pagination buttons', () => {
expect(wrapper.find('b-pagination-stub').exists()).toBe(false)
})
})
describe('list count greater than page size', () => {
beforeEach(async () => {
allContributions.value.listAllContributions.contributionCount = 33
})
it('has pagination buttons', () => {
expect(wrapper.find('b-pagination-stub').exists()).toBe(true)
})
})
describe('switch page', () => {
const scrollToMock = vi.fn()
window.scrollTo = scrollToMock
beforeEach(async () => {
allContributions.value.listAllContributions.contributionCount = 33
await wrapper
.findComponent({ name: 'PaginatorRouteParamsPage' })
.vm.$emit('update:modelValue', 2)
})
it('updates current page', () => {
expect(wrapper.vm.currentPage).toBe(2)
})
it.skip('scrolls to top', () => {
expect(scrollToMock).toHaveBeenCalledWith(0, 0)
})
})
})
})
})

View File

@ -0,0 +1,64 @@
<template>
<div v-if="items.length === 0 && !loading">
<div v-if="currentPage === 1">
{{ $t('contribution.noContributions.allContributions') }}
</div>
<div v-else>
{{ $t('contribution.noContributions.emptyPage') }}
</div>
</div>
<div v-else class="contribution-list-all">
<div v-for="item in items" :key="item.id + 'a'" class="mb-3">
<div :id="`contributionListItem-${item.id}`">
<contribution-list-all-item v-bind="item" />
</div>
</div>
</div>
<paginator-route-params-page
v-model="currentPage"
:total-count="contributionCount"
:loading="loading"
:page-size="pageSize"
/>
</template>
<script setup>
import { computed, ref } from 'vue'
import ContributionListAllItem from '@/components/Contributions/ContributionListAllItem.vue'
import { listAllContributions } from '@/graphql/contributions.graphql'
import { useQuery } from '@vue/apollo-composable'
import CONFIG from '@/config'
import PaginatorRouteParamsPage from '@/components/PaginatorRouteParamsPage.vue'
import { PAGE_SIZE } from '@/constants'
import { useRoute } from 'vue-router'
const route = useRoute()
// constants
const pollInterval = CONFIG.AUTO_POLL_INTERVAL || undefined
const pageSize = PAGE_SIZE
// computed
const currentPage = ref(Number(route.params.page) || 1)
const { result, loading } = useQuery(
listAllContributions,
() => ({
pagination: {
currentPage: currentPage.value,
pageSize,
order: 'DESC',
},
}),
{
fetchPolicy: 'cache-and-network',
pollInterval,
},
)
const contributionCount = computed(() => {
return result.value?.listAllContributions.contributionCount || 0
})
const items = computed(() => {
return [...(result.value?.listAllContributions.contributionList || [])]
})
</script>

View File

@ -0,0 +1,217 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ContributionListItem from './ContributionListItem'
import { BRow, BCol, BCollapse, BButton, BForm, BTextArea, BFormTextarea } from 'bootstrap-vue-next'
import VariantIcon from '@/components/VariantIcon.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
short: 'Short format date',
contribution: {
deleted: 'Deleted contribution',
delete: 'Delete contribution',
confirmed: 'Confirmed contribution',
},
form: {
reply: 'Reply',
memo: 'Memo',
},
edit: 'Edit',
delete: 'Delete',
moderatorChat: 'Chat',
},
},
datetimeFormats: {
en: {
short: {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
},
},
})
vi.mock('@vue/apollo-composable', () => ({
useQuery: vi.fn(),
useLazyQuery: vi.fn(() => ({
onResult: vi.fn(),
onError: vi.fn(),
load: vi.fn(),
refetch: vi.fn(),
})),
useMutation: vi.fn(() => ({
mutate: vi.fn(),
onDone: vi.fn(),
onError: vi.fn(),
})),
}))
vi.mock('@/composables/useToast', () => ({
useAppToast: vi.fn(() => ({
addToast: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
})),
}))
describe('ContributionListItem', () => {
let wrapper
const mocks = {
$filters: {
GDD: vi.fn((val) => val),
},
$t: vi.fn((key) => key),
$d: (date, format) => date.toISOString(),
}
const propsData = {
contributionId: 42,
contributionStatus: 'PENDING',
messagesCount: 2,
id: 1,
createdAt: '26/07/2022',
contributionDate: '07/06/2022',
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
amount: '200',
}
const mountWrapper = () => {
return mount(ContributionListItem, {
global: {
plugins: [i18n],
mocks,
stubs: [
'IBiPencil',
'IBiTrash',
'IBiChatDots',
'BAvatar',
'VariantIcon',
'BButton',
'IBiArrowDownCircle',
'IBiArrowUpCircle',
'IBiArrowUpShort',
'ContributionMessagesListItem',
],
components: {
BRow,
BCol,
BCollapse,
BButton,
BForm,
BTextArea,
BFormTextarea,
},
},
props: propsData,
})
}
describe('mount', () => {
beforeEach(() => {
vi.clearAllMocks()
wrapper = mountWrapper()
})
it('has a DIV .contribution-list-item', () => {
expect(wrapper.find('div.contribution-list-item').exists()).toBe(true)
})
describe('contribution icon', () => {
it('is bell-fill by default', () => {
expect(wrapper.vm.icon).toBe('bell-fill')
})
it('is x-circle when contributionStatus is DELETED', async () => {
await wrapper.setProps({ contributionStatus: 'DELETED' })
expect(wrapper.vm.icon).toBe('trash')
})
it('is check when contributionStatus is CONFIRMED', async () => {
await wrapper.setProps({ contributionStatus: 'CONFIRMED' })
expect(wrapper.vm.icon).toBe('check')
})
})
describe('contribution variant', () => {
it('is primary by default', () => {
expect(wrapper.vm.variant).toBe('primary')
})
it('is danger when contributionStatus is DELETED', async () => {
await wrapper.setProps({ contributionStatus: 'DELETED' })
expect(wrapper.vm.variant).toBe('danger')
})
it('is success at when contributionStatus is CONFIRMED', async () => {
await wrapper.setProps({ contributionStatus: 'CONFIRMED' })
expect(wrapper.vm.variant).toBe('success')
})
it('is warning at when contributionStatus is IN_PROGRESS', async () => {
await wrapper.setProps({ contributionStatus: 'IN_PROGRESS' })
expect(wrapper.vm.variant).toBe('205')
})
})
describe('delete contribution', () => {
describe('edit contribution', () => {
beforeEach(() => {
wrapper.find('div.test-edit-contribution').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(() => {
vi.spyOn(window, 'confirm').mockImplementation(() => true)
wrapper.find('div.test-delete-contribution').trigger('click')
})
it('emits delete contribution', () => {
expect(wrapper.emitted('contribution-changed')).toBeTruthy()
})
})
describe('cancel deletion', () => {
beforeEach(async () => {
vi.spyOn(window, 'confirm').mockImplementation(() => false)
await wrapper.find('div.test-delete-contribution').trigger('click')
})
it('does not emit delete contribution', () => {
expect(wrapper.emitted('delete-contribution')).toBeFalsy()
})
})
})
describe('updateStatus', () => {
it('updates status of a contribution', async () => {
wrapper.vm.contributionStatus = 'IN_PROGRESS'
wrapper.vm.addContributionMessage({})
expect(wrapper.vm.localStatus).toBe('PENDING')
})
})
})
})

View File

@ -0,0 +1,101 @@
<template>
<div>
<div class="contribution-list-item bg-white app-box-shadow gradido-border-radius pt-3 px-3">
<BRow>
<BCol cols="3" lg="2" md="2">
<app-avatar
v-if="username.username"
:name="username.username"
:initials="username.initials"
color="#fff"
class="vue3-avatar fw-bold"
/>
</BCol>
<BCol>
<div v-if="username.username" class="me-3 fw-bold">
{{ username.username }}
<variant-icon :icon="icon" :variant="variant" />
</div>
<div class="small">
{{ $d(new Date(contributionDate), 'short') }}
</div>
<div class="mt-3 fw-bold">{{ $t('contributionText') }}</div>
<div class="mb-3 text-break word-break">{{ memo }}</div>
<div v-if="updatedBy > 0" class="mt-2 mb-2 small">
{{ $t('moderatorChangedMemo') }}
</div>
</BCol>
<BCol cols="9" lg="3" offset="3" offset-md="0" offset-lg="0">
<div class="small">
{{ $t('creation') }} {{ $t('(') }}{{ hours }} {{ $t('h') }}{{ $t(')') }}
</div>
<div v-if="contributionStatus === 'DENIED'" class="fw-bold">
<variant-icon icon="x-circle" variant="danger" />
{{ $t('contribution.alert.denied') }}
</div>
<div v-if="contributionStatus === 'DELETED'" class="small">
{{ $t('contribution.deleted') }}
</div>
<div v-else class="fw-bold">{{ $filters.GDD(amount) }}</div>
</BCol>
<BCol cols="12" md="1" lg="1" class="text-end align-items-center" />
</BRow>
<div class="pb-3"></div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import AppAvatar from '@/components/AppAvatar.vue'
import { GDD_PER_HOUR } from '../../constants'
import { useContributionStatus } from '@/composables/useContributionStatus'
const props = defineProps({
amount: {
type: String,
},
memo: {
type: String,
},
user: {
type: Object,
required: false,
},
contributionDate: {
type: String,
},
updatedBy: {
type: Number,
required: false,
},
contributionStatus: {
type: String,
required: false,
default: '',
},
})
const { getVariant, getIcon } = useContributionStatus()
const variant = computed(() => getVariant(props.contributionStatus))
const icon = computed(() => getIcon(props.contributionStatus))
const username = computed(() => {
if (!props.user) return {}
return {
username: props.user.alias
? props.user.alias
: `${props.user.firstName} ${props.user.lastName}`,
initials: `${props.user.firstName[0]}${props.user.lastName[0]}`,
}
})
const hours = computed(() => parseFloat((props.amount / GDD_PER_HOUR).toFixed(2)))
</script>
<style lang="scss" scoped>
:deep(.b-avatar-custom > svg) {
width: 2.5em;
height: 2.5em;
}
</style>

View File

@ -1,11 +1,41 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import ContributionListItem from './ContributionListItem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ContributionListItem from './ContributionListItem'
import { BRow, BCol, BCollapse, BButton, BForm, BTextArea, BFormTextarea } from 'bootstrap-vue-next'
import VariantIcon from '@/components/VariantIcon.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
short: 'Short format date',
contribution: {
deleted: 'Deleted contribution',
delete: 'Delete contribution',
confirmed: 'Confirmed contribution',
},
form: {
reply: 'Reply',
memo: 'Memo',
},
edit: 'Edit',
delete: 'Delete',
moderatorChat: 'Chat',
},
},
datetimeFormats: {
en: {
short: {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
},
},
})
vi.mock('@vue/apollo-composable', () => ({
@ -26,6 +56,8 @@ vi.mock('@vue/apollo-composable', () => ({
vi.mock('@/composables/useToast', () => ({
useAppToast: vi.fn(() => ({
addToast: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
})),
}))
@ -36,11 +68,13 @@ describe('ContributionListItem', () => {
$filters: {
GDD: vi.fn((val) => val),
},
$t: vi.fn((key) => key),
$d: (date, format) => date.toISOString(),
}
const propsData = {
contributionId: 42,
status: 'PENDING',
contributionStatus: 'PENDING',
messagesCount: 2,
id: 1,
createdAt: '26/07/2022',
@ -54,6 +88,27 @@ describe('ContributionListItem', () => {
global: {
plugins: [i18n],
mocks,
stubs: [
'IBiPencil',
'IBiTrash',
'IBiChatDots',
'BAvatar',
'VariantIcon',
'BButton',
'IBiArrowDownCircle',
'IBiArrowUpCircle',
'IBiArrowUpShort',
'ContributionMessagesListItem',
],
components: {
BRow,
BCol,
BCollapse,
BButton,
BForm,
BTextArea,
BFormTextarea,
},
},
props: propsData,
})
@ -74,13 +129,13 @@ describe('ContributionListItem', () => {
expect(wrapper.vm.icon).toBe('bell-fill')
})
it('is x-circle when deletedAt is present', async () => {
await wrapper.setProps({ deletedAt: new Date().toISOString() })
it('is x-circle when contributionStatus is DELETED', async () => {
await wrapper.setProps({ contributionStatus: 'DELETED' })
expect(wrapper.vm.icon).toBe('trash')
})
it('is check when confirmedAt is present', async () => {
await wrapper.setProps({ confirmedAt: new Date().toISOString() })
it('is check when contributionStatus is CONFIRMED', async () => {
await wrapper.setProps({ contributionStatus: 'CONFIRMED' })
expect(wrapper.vm.icon).toBe('check')
})
})
@ -90,18 +145,18 @@ describe('ContributionListItem', () => {
expect(wrapper.vm.variant).toBe('primary')
})
it('is danger when deletedAt is present', async () => {
await wrapper.setProps({ deletedAt: new Date().toISOString() })
it('is danger when contributionStatus is DELETED', async () => {
await wrapper.setProps({ contributionStatus: 'DELETED' })
expect(wrapper.vm.variant).toBe('danger')
})
it('is success at when confirmedAt is present', async () => {
await wrapper.setProps({ confirmedAt: new Date().toISOString() })
it('is success at when contributionStatus is CONFIRMED', async () => {
await wrapper.setProps({ contributionStatus: 'CONFIRMED' })
expect(wrapper.vm.variant).toBe('success')
})
it('is warning at when status is IN_PROGRESS', async () => {
await wrapper.setProps({ status: 'IN_PROGRESS' })
it('is warning at when contributionStatus is IN_PROGRESS', async () => {
await wrapper.setProps({ contributionStatus: 'IN_PROGRESS' })
expect(wrapper.vm.variant).toBe('205')
})
})
@ -133,7 +188,7 @@ describe('ContributionListItem', () => {
})
it('emits delete contribution', () => {
expect(wrapper.emitted('delete-contribution')).toEqual([[{ id: 1 }]])
expect(wrapper.emitted('contribution-changed')).toBeTruthy()
})
})
@ -147,26 +202,15 @@ describe('ContributionListItem', () => {
expect(wrapper.emitted('delete-contribution')).toBeFalsy()
})
})
describe('updateStatus', () => {
beforeEach(async () => {
await wrapper.vm.updateStatus()
})
it('emit update-status', () => {
expect(wrapper.emitted('update-status')).toBeTruthy()
})
})
})
describe('getListContributionMessages', () => {
beforeEach(() => {
wrapper
.findComponent({ name: 'ContributionMessagesList' })
.vm.$emit('get-list-contribution-messages')
})
it('emits close-all-open-collapse', () => {
expect(wrapper.emitted('close-all-open-collapse')).toBeTruthy()
describe('updateStatus', () => {
it('updates status of a contribution', async () => {
wrapper.vm.contributionStatus = 'IN_PROGRESS'
wrapper.vm.addContributionMessage({})
expect(wrapper.vm.localStatus).toBe('PENDING')
})
})
})

View File

@ -2,34 +2,15 @@
<div>
<div
class="contribution-list-item bg-white app-box-shadow gradido-border-radius pt-3 px-3"
:class="status === 'IN_PROGRESS' && !allContribution ? 'pulse border border-205' : ''"
:class="localStatus === 'IN_PROGRESS' ? 'pulse border border-205' : ''"
>
<BRow>
<BCol cols="3" lg="2" md="2">
<!-- <avatar-->
<!-- v-if="firstName"-->
<!-- :name="username.username"-->
<!-- :initials="username.initials"-->
<!-- :border="false"-->
<!-- color="#fff"-->
<!-- class="vue3-avatar fw-bold"-->
<!-- />-->
<app-avatar
v-if="firstName"
:name="username.username"
:initials="username.initials"
color="#fff"
class="vue3-avatar fw-bold"
/>
<BAvatar v-else rounded="lg" :variant="variant" size="4.55em">
<BAvatar rounded="lg" :variant="variant" size="4.55em">
<variant-icon :icon="icon" variant="white" />
</BAvatar>
</BCol>
<BCol>
<div v-if="firstName" class="me-3 fw-bold">
{{ firstName }} {{ lastName }}
<variant-icon :icon="icon" :variant="variant" />
</div>
<div class="small">
{{ $d(new Date(contributionDate), 'short') }}
</div>
@ -39,9 +20,9 @@
{{ $t('moderatorChangedMemo') }}
</div>
<div
v-if="status === 'IN_PROGRESS' && !allContribution"
v-if="localStatus === 'IN_PROGRESS'"
class="text-danger pointer hover-font-bold"
@click="visible = !visible"
@click="emit('toggle-messages-visible')"
>
{{ $t('contribution.alert.answerQuestion') }}
</div>
@ -50,30 +31,23 @@
<div class="small">
{{ $t('creation') }} {{ $t('(') }}{{ hours }} {{ $t('h') }}{{ $t(')') }}
</div>
<div v-if="status === 'DENIED' && allContribution" class="fw-bold">
<variant-icon icon="x-circle" variant="danger" />
{{ $t('contribution.alert.denied') }}
</div>
<div v-if="status === 'DELETED'" class="small">
<div v-if="localStatus === 'DELETED'" class="small">
{{ $t('contribution.deleted') }}
</div>
<div v-else class="fw-bold">{{ $filters.GDD(amount) }}</div>
</BCol>
<BCol cols="12" md="1" lg="1" class="text-end align-items-center">
<div v-if="messagesCount > 0 && !moderatorId" @click="visible = !visible">
<collapse-icon class="text-end" :visible="visible" />
<div v-if="messagesCount > 0 && !moderatorId" @click="emit('toggle-messages-visible')">
<collapse-icon class="text-end" :visible="messagesVisible" />
</div>
</BCol>
</BRow>
<BRow
v-if="(!['CONFIRMED', 'DELETED'].includes(status) && !allContribution) || messagesCount > 0"
class="p-2"
>
<BRow v-if="!['CONFIRMED', 'DELETED'].includes(localStatus) || messagesCount > 0" class="p-2">
<BCol cols="3" class="me-auto text-center">
<div
v-if="!['CONFIRMED', 'DELETED'].includes(status) && !allContribution && !moderatorId"
v-if="!['CONFIRMED', 'DELETED'].includes(localStatus) && !moderatorId"
class="test-delete-contribution pointer me-3"
@click="deleteContribution({ id })"
@click="processDeleteContribution({ id })"
>
<IBiTrash />
@ -82,14 +56,14 @@
</BCol>
<BCol cols="3" class="text-center">
<div
v-if="!['CONFIRMED', 'DELETED'].includes(status) && !allContribution && !moderatorId"
v-if="!['CONFIRMED', 'DELETED'].includes(localStatus) && !moderatorId"
class="test-edit-contribution pointer me-3"
@click="
$emit('update-contribution-form', {
id: id,
contributionDate: contributionDate,
memo: memo,
amount: amount,
id,
contributionDate,
memo,
amount,
})
"
>
@ -98,22 +72,27 @@
</div>
</BCol>
<BCol cols="6" class="text-center">
<div v-if="messagesCount > 0 && !moderatorId" class="pointer" @click="visible = !visible">
<div
v-if="messagesCount > 0 && !moderatorId"
class="pointer"
@click="emit('toggle-messages-visible')"
>
<IBiChatDots />
<div>{{ $t('moderatorChat') }}</div>
</div>
</BCol>
</BRow>
<div v-else class="pb-3"></div>
<BCollapse :id="collapseId" :model-value="visible" class="mt-2">
<BCollapse :model-value="messagesVisible">
<contribution-messages-list
:messages="messagesGet"
:status="status"
:contribution-id="contributionId"
@get-list-contribution-messages="getListContributionMessages"
@update-status="updateStatus"
v-if="messagesCount > 0"
:messages="localMessages"
:status="localStatus"
:contribution-id="id"
@close-messages-list="emit('toggle-messages-visible')"
@add-contribution-message="addContributionMessage"
/>
</BCollapse>
<div class="pb-3"></div>
</div>
</div>
</template>
@ -122,12 +101,12 @@
import { ref, computed, watch } from 'vue'
import CollapseIcon from '../TransactionRows/CollapseIcon'
import ContributionMessagesList from '@/components/ContributionMessages/ContributionMessagesList'
import { listContributionMessages } from '@/graphql/queries'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
import { useLazyQuery } from '@vue/apollo-composable'
import AppAvatar from '@/components/AppAvatar.vue'
import { useMutation } from '@vue/apollo-composable'
import { GDD_PER_HOUR } from '../../constants'
import { deleteContribution } from '@/graphql/contributions.graphql'
import { useContributionStatus } from '@/composables/useContributionStatus'
const props = defineProps({
id: {
@ -139,45 +118,19 @@ const props = defineProps({
memo: {
type: String,
},
firstName: {
type: String,
messages: {
type: Array,
required: false,
},
lastName: {
type: String,
required: false,
},
createdAt: {
type: String,
default: () => [],
},
contributionDate: {
type: String,
},
deletedAt: {
type: String,
required: false,
},
confirmedBy: {
type: Number,
required: false,
},
confirmedAt: {
type: String,
required: false,
},
deniedBy: {
type: Number,
required: false,
},
deniedAt: {
type: String,
required: false,
},
updatedBy: {
type: Number,
required: false,
},
status: {
contributionStatus: {
type: String,
required: false,
default: '',
@ -186,112 +139,78 @@ const props = defineProps({
type: Number,
required: false,
},
contributionId: {
type: Number,
required: true,
},
allContribution: {
type: Boolean,
required: false,
default: false,
},
moderatorId: {
type: Number,
required: false,
default: 0,
},
messagesVisible: {
type: Boolean,
required: false,
default: false,
},
})
const { toastError } = useAppToast()
const { toastError, toastSuccess } = useAppToast()
const { t } = useI18n()
const { getVariant, getIcon } = useContributionStatus()
const messagesGet = ref([])
const visible = ref(false)
const { mutate: deleteContributionMutation } = useMutation(deleteContribution)
const variant = computed(() => {
if (props.deletedAt) return 'danger'
if (props.deniedAt) return 'warning'
if (props.confirmedAt) return 'success'
if (props.status === 'IN_PROGRESS') return '205'
return 'primary'
})
const localMessages = ref([])
const localStatus = ref(props.contributionStatus)
const variant = computed(() => getVariant(props.contributionStatus))
const icon = computed(() => getIcon(props.contributionStatus))
const icon = computed(() => {
if (props.deletedAt) return 'trash'
if (props.deniedAt) return 'x-circle'
if (props.confirmedAt) return 'check'
if (props.status === 'IN_PROGRESS') return 'question'
return 'bell-fill'
})
// if parent reload messages, update local messages copy
watch(
() => props.messages,
() => {
if (props.messages?.length > 0) {
localMessages.value = [...props.messages]
}
},
{ immediate: true },
)
const collapseId = computed(() => 'collapse' + String(props.id))
const username = computed(() => ({
username: `${props.firstName} ${props.lastName}`,
initials: `${props.firstName[0]}${props.lastName[0]}`,
}))
watch(
() => props.contributionStatus,
() => {
localStatus.value = props.contributionStatus
},
)
const hours = computed(() => parseFloat((props.amount / GDD_PER_HOUR).toFixed(2)))
watch(
() => visible.value,
() => {
if (visible.value) getListContributionMessages()
},
)
function deleteContribution(item) {
async function processDeleteContribution(item) {
if (props.allContribution) {
// eslint-disable-next-line no-console
console.warn('tried to delete contribution from all contributions')
return
}
if (window.confirm(t('contribution.delete'))) {
emit('delete-contribution', item)
}
}
const { onResult, onError, load, refetch } = useLazyQuery(listContributionMessages, {
contributionId: props.contributionId,
})
function getListContributionMessages(closeCollapse = true) {
if (closeCollapse) {
emit('close-all-open-collapse')
}
const variables = {
contributionId: props.contributionId,
}
// load works only once and return false on second call
if (!load(listContributionMessages, variables)) {
// update list data every time getListContributionMessages is called
// because it could be added new messages
refetch(variables)
}
}
onResult((resultValue) => {
if (resultValue.data) {
messagesGet.value.length = 0
resultValue.data.listContributionMessages.messages.forEach((message) => {
messagesGet.value.push(message)
})
}
})
onError((err) => {
toastError(err.message)
})
watch(
() => visible.value,
() => {
if (visible.value) {
getListContributionMessages()
try {
await deleteContributionMutation(item)
toastSuccess(t('contribution.deleted'))
localStatus.value = 'DELETED'
emit('contribution-changed')
} catch (err) {
toastError(err.message)
}
},
)
function updateStatus(id) {
emit('update-status', id)
}
}
const emit = defineEmits(['delete-contribution', 'close-all-open-collapse', 'update-status'])
function addContributionMessage(message) {
localMessages.value.push(message)
localStatus.value = 'PENDING'
emit('contribution-changed')
}
const emit = defineEmits([
'toggle-messages-visible',
'update-contribution-form',
'contribution-changed',
])
</script>
<style lang="scss" scoped>

View File

@ -60,9 +60,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const route = useRoute()
const { n } = useI18n()
const { value, meta, errorMessage } = useField(props.name, props.rules)
const amountFocused = ref(false)

View File

@ -2,7 +2,7 @@
<div :class="wrapperClassName">
<BFormGroup :label="label" :label-for="labelFor">
<BFormTextarea
v-if="textarea"
v-if="textarea === 'true'"
v-bind="{ ...$attrs, id: labelFor, name }"
v-model="model"
trim
@ -17,7 +17,7 @@
</template>
<script setup>
import { computed, defineOptions, defineModel } from 'vue'
import { computed, defineOptions, defineModel, watch } from 'vue'
defineOptions({
inheritAttrs: false,
})
@ -32,9 +32,9 @@ const props = defineProps({
required: true,
},
textarea: {
type: Boolean,
type: String,
required: false,
default: false,
default: 'false',
},
})

View File

@ -0,0 +1,54 @@
<template>
<BPagination
v-if="isPaginationVisible"
:model-value="currentPage"
class="mt-3"
pills
size="lg"
:per-page="pageSize"
:total-rows="totalCount"
align="center"
:hide-ellipsis="true"
@update:model-value="updatePage"
/>
</template>
<script setup>
import { computed } from 'vue'
import CONFIG from '@/config'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
pageSize: {
type: Number,
required: false,
default: CONFIG.PAGE_SIZE,
},
totalCount: {
type: Number,
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
})
const emit = defineEmits(['update:model-value'])
const isPaginationVisible = computed(() => {
return (props.totalCount > props.pageSize || props.modelValue > 1) && !props.loading
})
const currentPage = computed(() => Number(route.params.page) || props.modelValue)
const updatePage = (page) => {
router.push({ params: { page } })
emit('update:model-value', page)
}
</script>

View File

@ -4,8 +4,8 @@
class="nav-community-btn-wrapper bg-209 rounded-26 d-flex bd-highlight mx-xl-6 mx-lg-5 shadow justify-content-between"
>
<BButton
to="contribute"
active-class="btn-active svg-icon-active"
:to="{ path: '/community/contribute' }"
:class="stateClasses('/community/contribute')"
block
variant="link"
class="nav-community__btn"
@ -14,8 +14,8 @@
{{ $t('community.submitContribution') }}
</BButton>
<BButton
to="contributions"
active-class="btn-active svg-icon-active"
:to="{ path: '/community/contributions' }"
:class="stateClasses('/community/contributions')"
block
variant="link"
class="nav-community__btn"
@ -24,8 +24,8 @@
{{ $t('community.myContributions') }}
</BButton>
<BButton
to="community"
active-class="btn-active svg-icon-active"
:to="{ path: '/community/community' }"
:class="stateClasses('/community/community')"
block
variant="link"
class="nav-community__btn"
@ -39,6 +39,15 @@
<script>
export default {
name: 'NavCommunity',
methods: {
stateClasses(path) {
if (this.$route.path.includes(path)) {
return 'router-link-active router-link-exact-active'
}
return ''
},
},
}
</script>
<style scoped lang="scss">

View File

@ -0,0 +1,22 @@
export const useContributionStatus = () => {
const statusMapping = {
CONFIRMED: { variant: 'success', icon: 'check' },
DELETED: { variant: 'danger', icon: 'trash' },
DENIED: { variant: 'warning', icon: 'x-circle' },
IN_PROGRESS: { variant: '205', icon: 'question' },
default: { variant: 'primary', icon: 'bell-fill' },
}
const getVariant = (status) => {
return (statusMapping[status] || statusMapping.default).variant
}
const getIcon = (status) => {
return (statusMapping[status] || statusMapping.default).icon
}
return {
getVariant,
getIcon,
}
}

View File

@ -40,6 +40,7 @@ if (process.env.FRONTEND_HOSTING === 'nodejs') {
const features = {
GMS_ACTIVE: process.env.GMS_ACTIVE === 'true',
HUMHUB_ACTIVE: process.env.HUMHUB_ACTIVE === 'true',
AUTO_POLL_INTERVAL: Number.parseInt(process.env.AUTO_POLL_INTERVAL ?? 0),
}
const environment = {

View File

@ -42,6 +42,13 @@ module.exports = Joi.object({
.default('http://0.0.0.0/admin/authenticate?token=')
.required(),
AUTO_POLL_INTERVAL: Joi.number()
.integer()
.min(0)
.max(600000)
.description('Auto Polling for new data in ms. 0 = disabled = default. Experimental!')
.default(0),
COMMUNITY_REGISTER_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.description('URL for Register a new Account in frontend.')

View File

@ -1 +1,2 @@
export const GDD_PER_HOUR = 20
export const PAGE_SIZE = 25

View File

@ -0,0 +1,116 @@
#import './user.graphql'
fragment unconfirmedContributionFields on Contribution {
id
amount
memo
contributionDate
contributionStatus
messagesCount
}
fragment contributionFields on Contribution {
...unconfirmedContributionFields
createdAt
confirmedAt
confirmedBy
deniedAt
deniedBy
updatedBy
updatedAt
}
fragment contributionMessageFields on ContributionMessage {
id
message
createdAt
updatedAt
type
userFirstName
userLastName
userId
}
query listContributions ($pagination: Paginated!) {
listContributions(pagination: $pagination) {
contributionCount
contributionList {
id
amount
memo
contributionDate
contributionStatus
messagesCount
messages {
...contributionMessageFields
}
updatedBy
moderatorId
}
}
}
query listAllContributions ($pagination: Paginated!) {
listAllContributions(pagination: $pagination) {
contributionCount
contributionList {
amount
memo
user {
...userFields
}
contributionDate
updatedBy
contributionStatus
}
}
}
query countContributionsInProgress {
countContributionsInProgress
}
query openCreations {
openCreations {
year
month
amount
}
}
query openCreationsAmounts {
openCreations {
amount
}
}
# return unconfirmedContributionFields
mutation createContribution ($amount: Decimal!, $memo: String!, $contributionDate: String!) {
createContribution(amount: $amount, memo: $memo, contributionDate: $contributionDate) {
id
}
}
# return unconfirmedContributionFields
mutation updateContribution (
$contributionId: Int!,
$amount: Decimal!,
$memo: String!,
$contributionDate: String!
) {
updateContribution(
contributionId: $contributionId,
amount: $amount,
memo: $memo,
contributionDate: $contributionDate
) {
id
}
}
mutation deleteContribution($id: Int!) {
deleteContribution(id: $id)
}

View File

@ -134,36 +134,6 @@ export const redeemTransactionLink = gql`
}
`
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)
}
`
export const createContributionMessage = gql`
mutation ($contributionId: Int!, $message: String!) {
createContributionMessage(contributionId: $contributionId, message: $message) {

View File

@ -224,66 +224,6 @@ export const listContributionLinks = gql`
}
`
export const listContributions = gql`
query (
$currentPage: Int = 1
$pageSize: Int = 25
$order: Order = DESC
$statusFilter: [ContributionStatus!]
) {
listContributions(
currentPage: $currentPage
pageSize: $pageSize
order: $order
statusFilter: $statusFilter
) {
contributionCount
contributionList {
id
amount
memo
createdAt
contributionDate
confirmedAt
confirmedBy
deletedAt
status
messagesCount
deniedAt
deniedBy
updatedBy
updatedAt
moderatorId
}
}
}
`
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
status
messagesCount
deniedAt
deniedBy
updatedBy
updatedAt
}
}
}
`
export const communityStatistics = gql`
query {
communityStatistics {
@ -328,16 +268,6 @@ export const listContributionMessages = gql`
}
`
export const openCreations = gql`
query {
openCreations {
year
month
amount
}
}
`
export const user = gql`
query ($identifier: String!, $communityIdentifier: String!) {
user(identifier: $identifier, communityIdentifier: $communityIdentifier) {

View File

@ -0,0 +1,6 @@
fragment userFields on User {
id
firstName
lastName
alias
}

View File

@ -91,7 +91,8 @@
"lastContribution": "Letzte Beiträge",
"noContributions": {
"allContributions": "Es wurden noch keine Beiträge eingereicht.",
"myContributions": "Du hast noch keine Beiträge eingereicht."
"myContributions": "Du hast noch keine Beiträge eingereicht.",
"emptyPage": "Diese Seite ist leer."
},
"noOpenCreation": {
"allMonth": "Für alle beiden Monate ist dein Schöpfungslimit erreicht. Den Nächsten Monat kannst du wieder 1000 GDD Schöpfen.",
@ -323,7 +324,7 @@
"settings": "Einstellungen",
"circles": "Gradido Kreise",
"transactions": "Deine Transaktionen",
"usersearch": "Geografische Mitgliedssuche (experimentell)"
"usersearch": "Geografische Mitgliedssuche (beta)"
},
"qrCode": "QR Code",
"send_gdd": "GDD versenden",

View File

@ -91,7 +91,8 @@
"lastContribution": "Last Contributions",
"noContributions": {
"allContributions": "No contributions have been submitted yet.",
"myContributions": "You have not submitted any entries yet."
"myContributions": "You have not submitted any entries yet.",
"emptyPage": "This page is empty."
},
"noOpenCreation": {
"allMonth": "For all two months your creation limit is reached. The next month you can create 1000 GDD again.",
@ -323,7 +324,7 @@
"settings": "Settings",
"transactions": "Your transactions",
"circles": "Gradido Circles",
"usersearch": "Geographic member search (experimental)"
"usersearch": "Geographic member search (beta)"
},
"qrCode": "QR Code",
"send_gdd": "Send GDD",

View File

@ -284,7 +284,7 @@
"settings": "Soporte",
"transactions": "Tu Transacciones",
"circles": "Círculos Gradido",
"usersearch": "Búsqueda geográfica de miembros (experimental)"
"usersearch": "Búsqueda geográfica de miembros (beta)"
},
"qrCode": "Código QR",
"send_gdd": "Enviar GDD",

View File

@ -292,7 +292,7 @@
"settings": "Configuration",
"transactions": "Vos transactions",
"circles": "Cercles Gradido",
"usersearch": "Recherche géographique de membres (expérimentale)"
"usersearch": "Recherche géographique de membres (bèta)"
},
"qrCode": "QR Code",
"send_gdd": "Envoyer GDD",

View File

@ -284,7 +284,7 @@
"settings": "Settings",
"transactions": "Your transactions",
"circles": "Gradido Kringen",
"usersearch": "Geografisch leden zoeken (experimenteel)"
"usersearch": "Geografisch leden zoeken (bèta)"
},
"qrCode": "QR Code",
"send_gdd": "GDD sturen",

View File

@ -1,13 +1,10 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import Community from './Community'
import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations'
import { listContributions, listAllContributions, openCreations } from '@/graphql/queries'
import { useRoute, useRouter } from 'vue-router'
import { useAppToast } from '@/composables/useToast'
import { useMutation, useQuery } from '@vue/apollo-composable'
import { useQuery } from '@vue/apollo-composable'
import { mount } from '@vue/test-utils'
import { BTab, BTabs } from 'bootstrap-vue-next'
import { reactive, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRoute, useRouter } from 'vue-router'
import Community from './Community'
// Mock external dependencies
vi.mock('vue-router', () => ({
@ -17,13 +14,10 @@ vi.mock('vue-router', () => ({
vi.mock('@vue/apollo-composable', () => ({
useQuery: vi.fn(),
useMutation: vi.fn(),
}))
vi.mock('@/composables/useToast', () => ({
useAppToast: vi.fn(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastInfo: vi.fn(),
})),
}))
@ -34,36 +28,16 @@ vi.mock('vue-i18n', () => ({
})),
}))
vi.mock('vee-validate', () => ({
useField: vi.fn(() => ({
value: ref(''),
errorMessage: ref(''),
handleChange: vi.fn(),
meta: reactive({
valid: true,
touched: false,
dirty: false,
}),
})),
useForm: vi.fn(() => ({
handleSubmit: vi.fn(),
errors: reactive({}),
resetForm: vi.fn(),
})),
defineRule: vi.fn(),
}))
// Mock child components
vi.mock('@/components/Contributions/OpenCreationsAmount', () => ({
vi.mock('@/components/Contributions/ContributionEdit', () => ({
default: {
name: 'OpenCreationsAmount',
name: 'ContributionEdit',
template: '<div></div>',
},
}))
vi.mock('@/components/Contributions/ContributionForm', () => ({
vi.mock('@/components/Contributions/ContributionCreate', () => ({
default: {
name: 'ContributionForm',
name: 'ContributionCreate',
template: '<div></div>',
},
}))
@ -75,57 +49,41 @@ vi.mock('@/components/Contributions/ContributionList', () => ({
},
}))
vi.mock('@/components/Contributions/ContributionListAll', () => ({
default: {
name: 'ContributionListAll',
template: '<div></div>',
},
}))
describe('Community', () => {
let wrapper
let mockRouter
let mockToast
const mockOpenCreationsQuery = vi.fn()
const mockListContributionsQuery = vi.fn()
const mockListAllContributionsQuery = vi.fn()
const mockCreateContributionMutation = vi.fn()
const mockUpdateContributionMutation = vi.fn()
const mockDeleteContributionMutation = vi.fn()
const mockCountContributionsInProgress = vi.fn()
beforeEach(() => {
mockRouter = { push: vi.fn() }
vi.mocked(useRouter).mockReturnValue(mockRouter)
mockToast = {
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastInfo: vi.fn(),
}
vi.mocked(useAppToast).mockReturnValue(mockToast)
vi.mocked(useQuery).mockImplementation((query) => {
if (query === openCreations) return { onResult: mockOpenCreationsQuery, refetch: vi.fn() }
if (query === listContributions)
return { onResult: mockListContributionsQuery, refetch: vi.fn() }
if (query === listAllContributions)
return { onResult: mockListAllContributionsQuery, refetch: vi.fn() }
})
vi.mocked(useMutation).mockImplementation((mutation) => {
if (mutation === createContribution) return { mutate: mockCreateContributionMutation }
if (mutation === updateContribution) return { mutate: mockUpdateContributionMutation }
if (mutation === deleteContribution) return { mutate: mockDeleteContributionMutation }
})
const { defineRule } = require('vee-validate')
defineRule('required', (value) => !!value)
vi.mocked(useQuery).mockImplementation((query) => ({
onResult: mockCountContributionsInProgress,
}))
wrapper = mount(Community, {
global: {
mocks: {
$t: (key) => key, // Mock $t function
$d: (date) => date.toISOString(), // Mock $d function if needed
},
components: {
BTabs,
},
stub: {
BTab: true,
BTab,
},
},
})
@ -138,21 +96,13 @@ describe('Community', () => {
describe('mount', () => {
it('initializes with correct data', () => {
expect(wrapper.vm.tabIndex).toBe(0)
expect(wrapper.vm.items).toEqual([])
expect(wrapper.vm.itemsAll).toEqual([])
})
it('fetches initial data', () => {
expect(mockOpenCreationsQuery).toHaveBeenCalled()
expect(mockListContributionsQuery).toHaveBeenCalled()
expect(mockListAllContributionsQuery).toHaveBeenCalled()
})
})
describe('tabs', () => {
it('has three tabs', () => {
expect(wrapper.findAll('.tabs')).toHaveLength(1)
expect(wrapper.findAll('btab')).toHaveLength(3)
expect(wrapper.findAllComponents(BTab)).toHaveLength(3)
})
it.skip('updates tab index when route changes', async () => {
@ -162,118 +112,23 @@ describe('Community', () => {
})
})
describe('handleSaveContribution', () => {
it('calls createContributionMutation and shows success toast on success', async () => {
const contributionData = { date: '2023-09-12', memo: 'Test contribution', amount: '100' }
mockCreateContributionMutation.mockResolvedValue({ data: { createContribution: true } })
await wrapper.vm.handleSaveContribution(contributionData)
expect(mockCreateContributionMutation).toHaveBeenCalledWith({
creationDate: contributionData.date,
memo: contributionData.memo,
amount: contributionData.amount,
})
expect(mockToast.toastSuccess).toHaveBeenCalledWith('contribution.submitted')
})
it('shows error toast on failure', async () => {
const contributionData = { date: '2023-09-12', memo: 'Test contribution', amount: '100' }
mockCreateContributionMutation.mockRejectedValue(new Error('Create Contribution failed!'))
await wrapper.vm.handleSaveContribution(contributionData)
expect(mockToast.toastError).toHaveBeenCalledWith('Create Contribution failed!')
})
})
describe('handleUpdateContribution', () => {
it('calls updateContributionMutation and shows success toast on success', async () => {
const contributionData = {
id: 1,
date: '2023-09-12',
memo: 'Updated contribution',
amount: '200',
}
mockUpdateContributionMutation.mockResolvedValue({ data: { updateContribution: true } })
await wrapper.vm.handleUpdateContribution(contributionData)
expect(mockUpdateContributionMutation).toHaveBeenCalledWith({
contributionId: contributionData.id,
creationDate: contributionData.date,
memo: contributionData.memo,
amount: contributionData.amount,
})
expect(mockToast.toastSuccess).toHaveBeenCalledWith('contribution.updated')
})
it('shows error toast on failure', async () => {
const contributionData = {
id: 1,
date: '2023-09-12',
memo: 'Updated contribution',
amount: '200',
}
mockUpdateContributionMutation.mockRejectedValue(new Error('Update Contribution failed!'))
await wrapper.vm.handleUpdateContribution(contributionData)
expect(mockToast.toastError).toHaveBeenCalledWith('Update Contribution failed!')
})
})
describe('handleDeleteContribution', () => {
it('calls deleteContributionMutation and shows success toast on success', async () => {
const contributionData = { id: 1 }
mockDeleteContributionMutation.mockResolvedValue({ data: { deleteContribution: true } })
await wrapper.vm.handleDeleteContribution(contributionData)
expect(mockDeleteContributionMutation).toHaveBeenCalledWith({ id: contributionData.id })
expect(mockToast.toastSuccess).toHaveBeenCalledWith('contribution.deleted')
})
it('shows error toast on failure', async () => {
const contributionData = { id: 1 }
mockDeleteContributionMutation.mockRejectedValue(new Error('Delete Contribution failed!'))
await wrapper.vm.handleDeleteContribution(contributionData)
expect(mockToast.toastError).toHaveBeenCalledWith('Delete Contribution failed!')
})
})
describe('handleUpdateContributionForm', () => {
it('updates form data and changes tab', () => {
const contributionData = {
id: 2,
contributionDate: '2023-09-12',
memo: 'Test contribution',
amount: '300',
item: { id: 2, contributionDate: '2023-09-12', memo: 'Test contribution', amount: '300' },
page: 2,
}
wrapper.vm.handleUpdateContributionForm(contributionData)
expect(wrapper.vm.form).toEqual({
expect(wrapper.vm.itemData).toEqual({
id: 2,
date: '2023-09-12',
contributionDate: '2023-09-12',
memo: 'Test contribution',
amount: '300',
hours: 15, // 300 / 20
})
expect(wrapper.vm.tabIndex).toBe(0)
expect(mockRouter.push).toHaveBeenCalledWith({ params: { tab: 'contribute' } })
})
})
describe('updateStatus', () => {
it('updates status of a contribution', async () => {
wrapper.vm.items[0] = { id: 1, status: 'IN_PROGRESS' }
wrapper.vm.updateStatus(1)
expect(wrapper.vm.items[0].status).toBe('PENDING')
})
})
})

View File

@ -2,55 +2,19 @@
<div class="community-page">
<div>
<BTabs :model-value="tabIndex" no-nav-style borderless align="center">
<BTab no-body>
<open-creations-amount
:minimal-date="minimalDate"
:max-gdd-last-month="maxForMonths[0]"
:max-gdd-this-month="maxForMonths[1]"
/>
<div class="mb-3"></div>
<contribution-form
v-model="form"
:minimal-date="minimalDate"
:max-gdd-last-month="maxForMonths[0]"
:max-gdd-this-month="maxForMonths[1]"
@set-contribution="handleSaveContribution"
@update-contribution="handleUpdateContribution"
<BTab no-body lazy>
<contribution-edit
v-if="itemData"
v-model="itemData"
@contribution-updated="handleContributionUpdated"
/>
<contribution-create v-else />
</BTab>
<BTab no-body>
<div v-if="items.length === 0">
{{ $t('contribution.noContributions.myContributions') }}
</div>
<div v-else>
<contribution-list
:items="items"
:contribution-count="contributionCount"
:show-pagination="true"
:page-size="pageSize"
@close-all-open-collapse="closeAllOpenCollapse"
@update-list-contributions="handleUpdateListContributions"
@update-contribution-form="handleUpdateContributionForm"
@delete-contribution="handleDeleteContribution"
@update-status="updateStatus"
/>
</div>
<BTab no-body lazy>
<contribution-list @update-contribution-form="handleUpdateContributionForm" />
</BTab>
<BTab no-body>
<div v-if="itemsAll.length === 0">
{{ $t('contribution.noContributions.allContributions') }}
</div>
<div v-else>
<contribution-list
:items="itemsAll"
:contribution-count="contributionCountAll"
:show-pagination="true"
:page-size="pageSizeAll"
:all-contribution="true"
@update-list-contributions="handleUpdateListAllContributions"
@update-contribution-form="handleUpdateContributionForm"
/>
</div>
<BTab no-body lazy>
<contribution-list-all />
</BTab>
</BTabs>
</div>
@ -58,123 +22,41 @@
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuery, useMutation } from '@vue/apollo-composable'
import OpenCreationsAmount from '@/components/Contributions/OpenCreationsAmount'
import ContributionForm from '@/components/Contributions/ContributionForm'
import { useQuery } from '@vue/apollo-composable'
import ContributionEdit from '@/components/Contributions/ContributionEdit'
import ContributionCreate from '@/components/Contributions/ContributionCreate'
import ContributionList from '@/components/Contributions/ContributionList'
import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations'
import { listContributions, listAllContributions, openCreations } from '@/graphql/queries'
import ContributionListAll from '@/components/Contributions/ContributionListAll'
import { countContributionsInProgress } from '@/graphql/contributions.graphql'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
import { GDD_PER_HOUR } from '../constants'
const COMMUNITY_TABS = ['contribute', 'contributions', 'community']
const emit = defineEmits(['update-transactions'])
const route = useRoute()
const router = useRouter()
const { toastError, toastSuccess, toastInfo } = useAppToast()
const { toastInfo } = useAppToast()
const { t } = useI18n()
const tabIndex = ref(0)
const items = ref([])
const itemsAll = ref([])
const currentPage = ref(1)
const pageSize = ref(25)
const currentPageAll = ref(1)
const pageSizeAll = ref(25)
const contributionCount = ref(0)
const contributionCountAll = ref(0)
const form = ref({
id: null,
date: undefined,
memo: '',
hours: '',
amount: GDD_PER_HOUR,
})
const originalContributionDate = ref('')
const updateAmount = ref('')
const maximalDate = ref(new Date())
const openCreationsData = ref([])
const minimalDate = computed(() => {
const date = new Date(maximalDate.value)
return new Date(date.setMonth(date.getMonth() - 1, 1))
})
const itemData = ref(null)
const editContributionPage = ref(1)
const amountToAdd = computed(() => (form.value.id ? parseFloat(updateAmount.value) : 0.0))
const maxForMonths = computed(() => {
const originalDate = new Date(originalContributionDate.value)
if (openCreationsData.value && openCreationsData.value.length) {
return openCreationsData.value.slice(1).map((creation) => {
if (
creation.year === originalDate.getFullYear() &&
creation.month === originalDate.getMonth()
) {
return parseFloat(creation.amount) + amountToAdd.value
}
return parseFloat(creation.amount)
})
}
return [0, 0]
})
const { onResult: onOpenCreationsResult, refetch: refetchOpenCreations } = useQuery(
openCreations,
() => ({}),
const { onResult: handleInProgressContributionFound } = useQuery(
countContributionsInProgress,
{},
{
fetchPolicy: 'network-only',
},
)
const { onResult: onListAllContributionsResult, refetch: refetchAllContributions } = useQuery(
listAllContributions,
() => ({
currentPage: currentPageAll.value,
pageSize: pageSizeAll.value,
}),
{ fetchPolicy: 'no-cache' },
)
const { onResult: onListContributionsResult, refetch: refetchContributions } = useQuery(
listContributions,
() => ({
currentPage: currentPage.value,
pageSize: pageSize.value,
}),
{ fetchPolicy: 'network-only' },
)
const { mutate: createContributionMutation } = useMutation(createContribution)
const { mutate: updateContributionMutation } = useMutation(updateContribution)
const { mutate: deleteContributionMutation } = useMutation(deleteContribution)
onOpenCreationsResult(({ data }) => {
// jump to my contributions if in progress contribution found
handleInProgressContributionFound(({ data }) => {
if (data) {
openCreationsData.value = data.openCreations
}
})
onListAllContributionsResult(({ data }) => {
if (data) {
contributionCountAll.value = data.listAllContributions.contributionCount
itemsAll.value.length = 0
data.listAllContributions.contributionList.forEach((entry) => {
itemsAll.value.push(entry)
})
}
})
onListContributionsResult(({ data }) => {
if (data) {
contributionCount.value = data.listContributions.contributionCount
items.value.length = 0
data.listContributions.contributionList.forEach((entry) => {
items.value.push({ ...entry })
})
if (items.value.find((item) => item.status === 'IN_PROGRESS')) {
if (data.countContributionsInProgress > 0) {
tabIndex.value = 1
if (route.params.tab !== 'contributions') {
router.push({ params: { tab: 'contributions' } })
@ -187,96 +69,23 @@ onListContributionsResult(({ data }) => {
const updateTabIndex = () => {
const index = COMMUNITY_TABS.indexOf(route.params.tab)
tabIndex.value = index > -1 ? index : 0
closeAllOpenCollapse()
}
const closeAllOpenCollapse = () => {
document.querySelectorAll('.collapse.show').forEach((el) => {
el.classList.remove('show')
// after a edit contribution was saved, jump to contributions tab
function handleContributionUpdated() {
const contributionItemId = itemData.value.id
itemData.value = null
tabIndex.value = 1
router.push({
params: { tab: 'contributions', page: editContributionPage.value },
hash: `#contributionListItem-${contributionItemId}`,
})
}
const refetchData = () => {
refetchAllContributions()
refetchContributions()
refetchOpenCreations()
}
const handleSaveContribution = async (data) => {
try {
await createContributionMutation({
creationDate: data.date,
memo: data.memo,
amount: data.amount,
})
toastSuccess(t('contribution.submitted'))
refetchData()
} catch (err) {
toastError(err.message)
}
}
const handleUpdateContribution = async (data) => {
try {
await updateContributionMutation({
contributionId: data.id,
creationDate: data.date,
memo: data.memo,
amount: data.amount,
})
toastSuccess(t('contribution.updated'))
refetchData()
} catch (err) {
toastError(err.message)
}
}
const handleDeleteContribution = async (data) => {
try {
await deleteContributionMutation({
id: data.id,
})
toastSuccess(t('contribution.deleted'))
refetchData()
} catch (err) {
toastError(err.message)
}
}
const handleUpdateListAllContributions = (pagination) => {
currentPageAll.value = pagination.currentPage
pageSizeAll.value = pagination.pageSize
refetchAllContributions({
currentPage: currentPage.value,
pageSize: pageSize.value,
})
}
const handleUpdateListContributions = (pagination) => {
currentPage.value = pagination.currentPage
pageSize.value = pagination.pageSize
refetchContributions()
}
const handleUpdateContributionForm = (item) => {
form.value = {
id: item.id,
date: new Date(item.contributionDate).toISOString().slice(0, 10),
memo: item.memo,
amount: item.amount,
hours: item.amount / 20,
} //* /
originalContributionDate.value = item.contributionDate
updateAmount.value = item.amount
// if user clicks on edit contribution in contributions tab, jump to contribute tab and populate form with contribution data
const handleUpdateContributionForm = (data) => {
itemData.value = data.item
editContributionPage.value = data.page
tabIndex.value = 0
router.push({ params: { tab: 'contribute' } })
}
const updateStatus = (id) => {
const item = items.value.find((item) => item.id === id)
if (item) {
item.status = 'PENDING'
}
router.push({ params: { tab: 'contribute', page: undefined } })
}
watch(() => route.params.tab, updateTabIndex)

View File

@ -68,7 +68,7 @@ const routes = [
},
},
{
path: '/community/:tab',
path: '/community/:tab/:page?',
component: () => import('@/pages/Community.vue'),
meta: {
requiresAuth: true,

View File

@ -6,6 +6,11 @@
"lint": {
"dependsOn": ["stylelint", "locales"]
},
"stylelint:fix": {},
"locales:fix": {},
"lint:fix": {
"dependsOn": ["stylelint:fix", "locales:fix"]
},
"compile-scss": {},
"watch-scss": {
"cache": false,

View File

@ -111,6 +111,7 @@ export default defineConfig(async ({ command }) => {
autoInstall: true,
}),
EnvironmentPlugin({
AUTO_POLL_INTERVAL: CONFIG.AUTO_POLL_INTERVAL,
BUILD_COMMIT: CONFIG.BUILD_COMMIT,
GMS_ACTIVE: CONFIG.GMS_ACTIVE,
HUMHUB_ACTIVE: CONFIG.HUMHUB_ACTIVE,