refactor contributions in frontend

This commit is contained in:
einhornimmond 2025-05-13 09:16:53 +02:00
parent 2cabeb87bb
commit c4238a897c
28 changed files with 591 additions and 597 deletions

View File

@ -34,8 +34,8 @@
{{ t('help.transactionlist.confirmed') }}
</div>
<div>
{{ t('transactionlist.status') }} {{ t('math.equals') }}
{{ t('help.transactionlist.status') }}
{{ t('transactionlist.contributionStatus') }} {{ t('math.equals') }}
{{ t('help.transactionlist.contributionStatus') }}
</div>
</BCollapse>
</div>

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

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

View File

@ -1,64 +1,27 @@
import { Contribution as dbContribution } from '@entity/Contribution'
import { ContributionMessage as dbContributionMessage } from '@entity/ContributionMessage'
import { Decimal } from 'decimal.js-light'
import { Field, Int, ObjectType } from 'type-graphql'
import { ContributionMessage } from './ContributionMessage'
import { User } from './User'
import { UnconfirmedContribution } from './UnconfirmedContribution'
@ObjectType()
export class Contribution {
export class Contribution extends UnconfirmedContribution {
constructor(contribution: dbContribution) {
const user = contribution.user
this.id = contribution.id
this.firstName = user?.firstName ?? null
this.lastName = user?.lastName ?? null
this.amount = contribution.amount
this.memo = contribution.memo
super(contribution)
this.createdAt = contribution.createdAt
this.confirmedAt = contribution.confirmedAt
this.confirmedBy = contribution.confirmedBy
this.contributionDate = contribution.contributionDate
this.status = contribution.contributionStatus
this.messagesCount = 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
this.user = user ? new User(user) : null
this.messages = contribution.messages
? contribution.messages.map(
(message: dbContributionMessage) => new ContributionMessage(message),
)
: null
}
@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
@ -87,19 +50,7 @@ export class Contribution {
updatedBy: number | null
@Field(() => Date)
contributionDate: Date
@Field(() => Int)
messagesCount: number
@Field(() => [ContributionMessage], { nullable: true })
messages: ContributionMessage[] | null
@Field(() => String)
status: string
@Field(() => Int, { nullable: true })
moderatorId: number | null
contributionDate: Date
@Field(() => Date, { nullable: true })
resubmissionAt: Date | null

View File

@ -1,42 +1,44 @@
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
import { ContributionMessage as dbContributionMessage } from '@entity/ContributionMessage'
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[]) {
constructor(contribution: Contribution) {
const user = contribution.user
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
}
this.contributionDate = contribution.contributionDate
this.user = user ? new User(user) : null
this.moderatorId = contribution.moderatorId
this.contributionStatus = contribution.contributionStatus
this.messagesCount = contribution.messages ? contribution.messages.length : 0
@Field(() => String)
firstName: string
this.messages = contribution.messages
? contribution.messages.map(
(message: dbContributionMessage) => new ContributionMessage(message),
)
: 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
@ -45,14 +47,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 @@ export class ContributionResolver {
@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)
@ -93,14 +93,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
@ -109,7 +109,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])
@ -144,12 +144,12 @@ export class ContributionResolver {
@Query(() => ContributionListResult)
async listContributions(
@Ctx() context: Context,
@Args()
paginated: Paginated,
@Arg('pagination') pagination: Paginated,
@Arg('messagePagination') messagePagination: Paginated,
): Promise<ContributionListResult> {
const user = getUser(context)
const [dbContributions, count] = await loadUserContributions(user.id, paginated)
const [dbContributions, count] = await loadUserContributions(user.id, pagination, messagePagination)
// show contributions in progress first
const inProgressContributions = dbContributions.filter(
(contribution) => contribution.contributionStatus === ContributionStatus.IN_PROGRESS,
@ -163,10 +163,10 @@ export class ContributionResolver {
[...inProgressContributions, ...notInProgressContributions].map((contribution) => {
// we currently expect not much contribution messages for needing pagination
// but if we get more than expected, we should get warned
if ((contribution.messages?.length || 0) > 10) {
if ((contribution.messages?.length || 0) > messagePagination.pageSize) {
logger.warn('more contribution messages as expected, consider pagination', {
contributionId: contribution.id,
expected: 10,
expected: messagePagination.pageSize,
actual: contribution.messages?.length || 0,
})
}
@ -191,10 +191,9 @@ export class ContributionResolver {
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTIONS])
@Query(() => ContributionListResult)
async listAllContributions(
@Args()
paginated: Paginated,
@Arg('pagination') pagination: Paginated,
): Promise<ContributionListResult> {
const [dbContributions, count] = await loadAllContributions(paginated)
const [dbContributions, count] = await loadAllContributions(pagination)
return new ContributionListResult(
count,
@ -215,8 +214,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) {
@ -226,7 +224,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])
@ -629,25 +627,28 @@ export class ContributionResolver {
// Field resolvers
@Authorized([RIGHTS.USER])
@FieldResolver(() => User)
@FieldResolver(() => User, { nullable: true })
async user(
@Root() contribution: DbContribution,
@Info() info: GraphQLResolveInfo,
): Promise<User> {
let user = contribution.user
): Promise<User | null> {
let user: DbUser | null = 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()
user = await queryBuilder.getOne()
}
return new User(user)
if (user) {
return new User(user)
}
return null
}
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES])
@FieldResolver(() => [ContributionMessage], { nullable: true })
async messages(
@Root() contribution: Contribution,
@Root() contribution: UnconfirmedContribution,
@Arg('pagination', () => Paginated) pagination: Paginated,
): Promise<ContributionMessage[] | null> {
if (contribution.messagesCount === 0) {

View File

@ -73,6 +73,7 @@ export const getUserCreations = async (
await sumAmountContributionPerUserAndLast3MonthQuery.getRawMany()
logger.trace(sumAmountContributionPerUserAndLast3Month)
console.log(JSON.stringify(sumAmountContributionPerUserAndLast3Month, null, 2))
await queryRunner.release()

View File

@ -3,9 +3,8 @@ import { FindManyOptions } from '@dbTools/typeorm'
import { Contribution as DbContribution } from '@entity/Contribution'
function buildBaseOptions(paginated: Paginated): FindManyOptions<DbContribution> {
const { order, currentPage, pageSize } = paginated
const { currentPage, pageSize } = paginated
return {
order: { createdAt: order, id: order },
skip: (currentPage - 1) * pageSize,
take: pageSize,
}
@ -19,10 +18,15 @@ function buildBaseOptions(paginated: Paginated): FindManyOptions<DbContribution>
export const loadUserContributions = async (
userId: number,
paginated: Paginated,
messagePagination: Paginated,
): Promise<[DbContribution[], number]> => {
const { order } = paginated
const { order: messageOrder } = messagePagination
return DbContribution.findAndCount({
where: { userId },
withDeleted: true,
relations: { messages: { user: true } },
order: { createdAt: order, id: order, messages: { createdAt: messageOrder } },
...buildBaseOptions(paginated),
})
}
@ -34,8 +38,10 @@ export const loadUserContributions = async (
export const loadAllContributions = async (
paginated: Paginated,
): Promise<[DbContribution[], number]> => {
const { order } = paginated
return DbContribution.findAndCount({
relations: { user: true },
relations: { user: { emailContact: true } },
order: { createdAt: order, id: order},
...buildBaseOptions(paginated),
})
}

View File

@ -18,14 +18,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

@ -39,11 +39,7 @@ const props = defineProps({
},
})
const emit = defineEmits([
'get-list-contribution-messages',
'update-status',
'add-contribution-message',
])
const emit = defineEmits(['get-list-contribution-messages', 'add-contribution-message'])
const { t } = useI18n()
const { toastSuccess, toastError } = useAppToast()
@ -68,7 +64,6 @@ async function onSubmit() {
// emit('get-list-contribution-messages', false)
formText.value = ''
emit('update-status', props.contributionId)
emit('add-contribution-message', result.data.createContributionMessage)
toastSuccess(t('message.reply'))
} catch (error) {

View File

@ -14,7 +14,7 @@
</div>
<div class="text-center pointer clearboth">
<BButton variant="outline-primary" block @click="$emit('close-all-open-collapse')">
<BButton variant="outline-primary" block @click="$emit('close-messages-list')">
<IBiArrowUpShort />
{{ $t('form.close') }}
</BButton>

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,56 @@
<template>
<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"
@reset-form="resetForm"
/>
</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())
function emptyForm() {
return {
contributionDate: undefined,
memo: '',
hours: '',
amount: GDD_PER_HOUR,
}
}
async function handleCreateContribution(contribution) {
try {
await createContributionMutation({ ...contribution })
toastSuccess(t('contribution.submitted'))
resetForm()
refetch()
} catch (err) {
toastError(err.message)
}
}
function resetForm() {
form.value = emptyForm()
}
</script>

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"
@reset-form="emit('reset-form')"
/>
</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', 'reset-form'])
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,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('reset-form')"
>
{{ $t('form.cancel') }}
</BButton>
@ -80,7 +86,7 @@
</template>
<script setup>
import { reactive, computed, watch } from 'vue'
import { reactive, computed, watch, ref, onMounted, onUnmounted, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import ValidatedInput from '@/components/Inputs/ValidatedInput'
import LabeledInput from '@/components/Inputs/LabeledInput'
@ -88,35 +94,42 @@ import { memo as memoSchema } from '@/validationSchemas'
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 entityDataToForm = (entityData) => ({
...entityData,
hours: entityData.hours !== undefined ? entityData.hours : amountToHours(entityData.amount),
contributionDate: entityData.contributionDate
? new Date(entityData.contributionDate).toISOString().slice(0, 10)
: undefined,
})
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', 'update:modelValue', 'reset-form'])
const { t } = useI18n()
const form = reactive({ ...props.modelValue })
const form = reactive(entityDataToForm(props.modelValue))
// 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),
)
// 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 +142,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 +162,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 +178,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,7 +1,9 @@
import { listAllContributions, listContributions } from '@/graphql/contributions.graphql'
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'
const i18n = createI18n({
legacy: false,
@ -15,6 +17,10 @@ vi.mock('@/components/Contributions/ContributionListItem.vue', () => ({
},
}))
vi.mock('@vue/apollo-composable', () => ({
useQuery: vi.fn(),
}))
describe('ContributionList', () => {
let wrapper
@ -30,10 +36,38 @@ describe('ContributionList', () => {
},
}
const contributionsList = {
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 propsData = {
contributionCount: 3,
showPagination: true,
pageSize: 25,
allContribution: false,
items: [
{
id: 0,
@ -45,7 +79,7 @@ describe('ContributionList', () => {
{
id: 1,
date: '06/22/2022',
memo: 'Ich habe 30 Stunden Frau Müller beim EInkaufen und im Haushalt geholfen.',
memo: 'Ich habe 30 Stunden Frau Müller beim Einkaufen und im Haushalt geholfen.',
amount: '600',
status: 'CONFIRMED',
},
@ -67,10 +101,44 @@ describe('ContributionList', () => {
}
describe('mount', () => {
const mockListContributionsQuery = vi.fn()
const mockListAllContributionsQuery = vi.fn()
beforeEach(() => {
vi.mocked(useQuery).mockImplementation((query) => {
if (query === listContributions) {
return { onResult: mockListContributionsQuery, refetch: vi.fn() }
}
if (query === listAllContributions) {
return { onResult: mockListAllContributionsQuery, refetch: vi.fn() }
}
})
wrapper = mountWrapper()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('mount as user contributions list', () => {
beforeEach(() => {
propsData.allContribution = false
})
it('fetches initial data', () => {
expect(mockListContributionsQuery).toHaveBeenCalled()
})
})
describe('mount as all contributions list', () => {
beforeEach(() => {
propsData.allContribution = true
})
it('fetches initial data', () => {
expect(mockListAllContributionsQuery).toHaveBeenCalled()
})
})
it('has a DIV .contribution-list', () => {
expect(wrapper.find('div.contribution-list').exists()).toBe(true)
})

View File

@ -1,5 +1,5 @@
<template>
<div v-if="items.length === 0 && !loading.value">
<div v-if="items.length === 0 && !loading">
{{ emptyText }}
</div>
<div v-else class="contribution-list">
@ -8,9 +8,10 @@
v-bind="item"
:contribution-id="item.id"
:all-contribution="allContribution"
@close-all-open-collapse="$emit('close-all-open-collapse')"
:messages-visible="openMessagesListId === item.id"
@toggle-messages-visible="toggleMessagesVisible(item.id)"
@update-contribution-form="updateContributionForm"
@delete-contribution="deleteContribution"
@contribution-changed="refetch()"
/>
</div>
<BPagination
@ -33,6 +34,8 @@ import ContributionListItem from '@/components/Contributions/ContributionListIte
import { listContributions, listAllContributions } from '@/graphql/contributions.graphql'
import { useQuery } from '@vue/apollo-composable'
import { PAGE_SIZE } from '@/constants'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
const props = defineProps({
allContribution: {
@ -47,45 +50,65 @@ const props = defineProps({
},
})
const emit = defineEmits([
'close-all-open-collapse',
'update-contribution-form',
'delete-contribution',
])
// composables
const { toastError, toastSuccess } = useAppToast()
const { t } = useI18n()
// constants
const pageSize = PAGE_SIZE
// refs
const currentPage = ref(1)
const openMessagesListId = ref(null)
// queries
const { result, loading, refetch } = useQuery(
props.allContribution ? listAllContributions : listContributions,
() => ({
pagination: {
currentPage: currentPage.value,
pageSize,
order: 'DESC',
},
messagePagination: !props.allContribution
? {
currentPage: 1,
pageSize: 10,
order: 'ASC',
}
: undefined,
}),
{ fetchPolicy: 'cache-and-network' },
)
// events
const emit = defineEmits(['update-contribution-form'])
// computed
const contributionListResult = computed(() => {
return props.allContribution
? result.value?.listAllContributions
: result.value?.listContributions
})
const contributionCount = computed(() => {
return contributionListResult.value?.contributionCount || 0
})
const items = computed(() => {
return contributionListResult.value?.contributionList || []
})
const isPaginationVisible = computed(() => {
return PAGE_SIZE < contributionCount.value
return contributionCount.value > pageSize
})
const toggleMessagesVisible = (contributionId) => {
if (openMessagesListId.value === contributionId) {
openMessagesListId.value = 0
} else {
openMessagesListId.value = contributionId
}
}
const { result, loading } = useQuery(
props.allContribution ? listAllContributions : listContributions,
() => ({
currentPage: currentPage.value,
pageSize: PAGE_SIZE,
}),
{ fetchPolicy: 'cache-and-network' },
)
// methods
const updateContributionForm = (item) => {
emit('update-contribution-form', item)
}
const deleteContribution = (item) => {
emit('delete-contribution', item)
}
</script>

View File

@ -1,7 +1,7 @@
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'
const i18n = createI18n({
legacy: false,
@ -169,5 +169,14 @@ describe('ContributionListItem', () => {
expect(wrapper.emitted('close-all-open-collapse')).toBeTruthy()
})
})
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

@ -6,16 +6,8 @@
>
<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"
v-if="username.username"
:name="username.username"
:initials="username.initials"
color="#fff"
@ -26,8 +18,8 @@
</BAvatar>
</BCol>
<BCol>
<div v-if="firstName" class="me-3 fw-bold">
{{ firstName }} {{ lastName }}
<div v-if="username.username" class="me-3 fw-bold">
{{ username.username }}
<variant-icon :icon="icon" :variant="variant" />
</div>
<div class="small">
@ -41,7 +33,7 @@
<div
v-if="localStatus === 'IN_PROGRESS' && !allContribution"
class="text-danger pointer hover-font-bold"
@click="visible = !visible"
@click="emit('toggle-messages-visible')"
>
{{ $t('contribution.alert.answerQuestion') }}
</div>
@ -60,8 +52,8 @@
<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>
@ -77,7 +69,7 @@
!['CONFIRMED', 'DELETED'].includes(localStatus) && !allContribution && !moderatorId
"
class="test-delete-contribution pointer me-3"
@click="deleteContribution({ id })"
@click="processDeleteContribution({ id })"
>
<IBiTrash />
@ -92,10 +84,10 @@
class="test-edit-contribution pointer me-3"
@click="
$emit('update-contribution-form', {
id: id,
contributionDate: contributionDate,
memo: memo,
amount: amount,
id,
contributionDate,
memo,
amount,
})
"
>
@ -104,20 +96,23 @@
</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>
<BCollapse :model-value="visible">
<BCollapse :model-value="messagesVisible">
<contribution-messages-list
:messages="messagesGet"
v-if="messagesCount > 0"
:messages="localMessages"
:status="localStatus"
:contribution-id="contributionId"
@get-list-contribution-messages="getListContributionMessages"
@update-status="updateStatus"
@close-all-open-collapse="visible = false"
@close-messages-list="emit('toggle-messages-visible')"
@add-contribution-message="addContributionMessage"
/>
</BCollapse>
@ -130,12 +125,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 { useMutation } from '@vue/apollo-composable'
import AppAvatar from '@/components/AppAvatar.vue'
import { GDD_PER_HOUR } from '../../constants'
import { deleteContribution } from '@/graphql/contributions.graphql'
const props = defineProps({
id: {
@ -150,13 +145,10 @@ const props = defineProps({
messages: {
type: Array,
required: false,
default: () => [],
},
firstName: {
type: String,
required: false,
},
lastName: {
type: String,
user: {
type: Object,
required: false,
},
createdAt: {
@ -189,7 +181,7 @@ const props = defineProps({
type: Number,
required: false,
},
status: {
contributionStatus: {
type: String,
required: false,
default: '',
@ -212,103 +204,89 @@ const props = defineProps({
required: false,
default: 0,
},
messagesVisible: {
type: Boolean,
required: false,
default: false,
},
})
const { toastError } = useAppToast()
const { toastError, toastSuccess } = useAppToast()
const { t } = useI18n()
const messagesGet = ref([])
const visible = ref(false)
const localStatus = ref(props.status)
const { mutate: deleteContributionMutation } = useMutation(deleteContribution)
const localMessages = ref([])
const localStatus = ref(props.contributionStatus)
// if parent reload messages, update local messages copy
watch(
() => props.messages,
() => {
messagesGet.value = props.messages
if (props.messages?.length > 0) {
localMessages.value = [...props.messages]
}
},
// parent is loading messages already
{ immediate: false },
{ immediate: true },
)
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 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'
return (statusMapping[localStatus.value] || statusMapping.default).variant
})
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'
return (statusMapping[localStatus.value] || statusMapping.default).icon
})
const collapseId = computed(() => 'collapse' + String(props.id))
const username = computed(() => ({
username: `${props.firstName} ${props.lastName}`,
initials: `${props.firstName[0]}${props.lastName[0]}`,
}))
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)))
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)
try {
await deleteContributionMutation(item)
toastSuccess(t('contribution.deleted'))
localStatus.value = 'DELETED'
emit('contribution-changed')
} catch (err) {
toastError(err.message)
}
}
}
function addContributionMessage(message) {
messagesGet.value.push(message)
}
onResult((resultValue) => {
if (resultValue.data) {
messagesGet.value.length = 0
resultValue.data.listContributionMessages.messages.forEach((message) => {
messagesGet.value.push(message)
})
}
})
onError((err) => {
toastError(err.message)
})
const updateStatus = (id) => {
localMessages.value.push(message)
localStatus.value = 'PENDING'
emit('contribution-changed')
}
const emit = defineEmits(['delete-contribution', 'close-all-open-collapse'])
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

@ -1,14 +1,19 @@
#import './user.graphql'
fragment contributionFields on Contribution {
fragment unconfirmedContributionFields on Contribution {
id
amount
memo
createdAt
contributionDate
contributionStatus
messagesCount
}
fragment contributionFields on Contribution {
...unconfirmedContributionFields
createdAt
confirmedAt
confirmedBy
status
messagesCount
deniedAt
deniedBy
updatedBy
@ -26,34 +31,77 @@ fragment contributionMessageFields on ContributionMessage {
userId
}
query listContributions ($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {
listContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
query listContributions ($pagination: Paginated!, $messagePagination: Paginated!) {
listContributions(pagination: $pagination, messagePagination: $messagePagination) {
contributionCount
contributionList {
...contributionFields
deletedAt
moderatorId
messages(pagination: { currentPage: 1, pageSize: 10, order: DESC }) {
messages(pagination: $messagePagination) {
...contributionMessageFields
}
}
}
}
query listAllContributions ($pagination: Paginated!) {
listAllContributions(pagination: $pagination) {
contributionCount
contributionList {
user {
...userFields
}
...contributionFields
}
}
}
query countContributionsInProgress {
countContributionsInProgress
}
query listAllContributions ($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
contributionCount
contributionList {
firstName
lastName
...contributionFields
}
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

@ -187,56 +187,6 @@ export const listContributionLinks = gql`
}
`
export const listContributions = gql`
query ($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {
listContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
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 {
@ -281,16 +231,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

@ -1,13 +1,14 @@
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 { countContributionsInProgress } from '@/graphql/contributions.graphql'
import { createContribution, deleteContribution, updateContribution } from '@/graphql/mutations'
import { listAllContributions, listContributions, openCreations } from '@/graphql/queries'
import { useMutation, useQuery } from '@vue/apollo-composable'
import { mount } from '@vue/test-utils'
import { BTab, BTabs } from 'bootstrap-vue-next'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Community from './Community'
// Mock external dependencies
vi.mock('vue-router', () => ({
@ -80,9 +81,8 @@ describe('Community', () => {
let mockRouter
let mockToast
const mockCountContributionsInProgress = vi.fn()
const mockOpenCreationsQuery = vi.fn()
const mockListContributionsQuery = vi.fn()
const mockListAllContributionsQuery = vi.fn()
const mockCreateContributionMutation = vi.fn()
const mockUpdateContributionMutation = vi.fn()
const mockDeleteContributionMutation = vi.fn()
@ -99,17 +99,34 @@ describe('Community', () => {
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() }
if (query === openCreations) {
return {
onResult: mockOpenCreationsQuery,
refetch: vi.fn(),
}
}
if (query === countContributionsInProgress) {
return { onResult: mockCountContributionsInProgress }
}
})
vi.mocked(useMutation).mockImplementation((mutation) => {
if (mutation === createContribution) return { mutate: mockCreateContributionMutation }
if (mutation === updateContribution) return { mutate: mockUpdateContributionMutation }
if (mutation === deleteContribution) return { mutate: mockDeleteContributionMutation }
if (mutation === createContribution) {
return {
mutate: mockCreateContributionMutation,
}
}
if (mutation === updateContribution) {
return {
mutate: mockUpdateContributionMutation,
}
}
if (mutation === deleteContribution) {
return {
mutate: mockDeleteContributionMutation,
}
}
})
const { defineRule } = require('vee-validate')
@ -138,14 +155,11 @@ 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()
expect(mockCountContributionsInProgress).toHaveBeenCalled()
})
})
@ -266,14 +280,4 @@ describe('Community', () => {
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

@ -3,26 +3,18 @@
<div>
<BTabs :model-value="tabIndex" no-nav-style borderless align="center">
<BTab no-body lazy>
<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"
<contribution-edit
v-if="itemToEdit"
v-model="itemToEdit"
@contribution-updated="handleContributionUpdated"
@reset-form="itemToEdit = null"
/>
<contribution-create v-else />
</BTab>
<BTab no-body lazy>
<contribution-list
:empty-text="$t('contribution.noContributions.myContributions')"
@update-contribution-form="handleUpdateContributionForm"
@delete-contribution="handleDeleteContribution"
/>
</BTab>
<BTab no-body lazy>
@ -39,12 +31,11 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuery, useMutation } from '@vue/apollo-composable'
import { useQuery } from '@vue/apollo-composable'
import OpenCreationsAmount from '@/components/Contributions/OpenCreationsAmount'
import ContributionForm from '@/components/Contributions/ContributionForm'
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 { openCreations } from '@/graphql/queries'
import { countContributionsInProgress } from '@/graphql/contributions.graphql'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
@ -52,8 +43,6 @@ import { GDD_PER_HOUR } from '../constants'
const COMMUNITY_TABS = ['contribute', 'contributions', 'community']
const emit = defineEmits(['update-transactions'])
const route = useRoute()
const router = useRouter()
@ -61,59 +50,8 @@ const { toastError, toastSuccess, 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 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,
() => ({}),
{
fetchPolicy: 'network-only',
},
)
const { mutate: createContributionMutation } = useMutation(createContribution)
const { mutate: updateContributionMutation } = useMutation(updateContribution)
const { mutate: deleteContributionMutation } = useMutation(deleteContribution)
const itemToEdit = ref(null)
const { onResult: handleInProgressContributionFound } = useQuery(
countContributionsInProgress,
@ -122,18 +60,10 @@ const { onResult: handleInProgressContributionFound } = useQuery(
fetchPolicy: 'network-only',
},
)
onOpenCreationsResult(({ data }) => {
if (data) {
openCreationsData.value = data.openCreations
}
})
// jump to my contributions if in progress contribution found
handleInProgressContributionFound(({ data }) => {
if (data) {
const count = data.countContributionsInProgress
if (count > 0) {
if (data.countContributionsInProgress > 0) {
tabIndex.value = 1
if (route.params.tab !== 'contributions') {
router.push({ params: { tab: 'contributions' } })
@ -147,63 +77,15 @@ const updateTabIndex = () => {
const index = COMMUNITY_TABS.indexOf(route.params.tab)
tabIndex.value = index > -1 ? index : 0
}
const refetchData = () => {
refetchOpenCreations()
handleInProgressContributionFound()
// after a edit contribution was saved, jump to contributions tab
function handleContributionUpdated() {
itemToEdit.value = null
tabIndex.value = 1
router.push({ params: { tab: 'contributions' } })
}
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)
}
}
// if user clicks on edit contribution in contributions tab, jump to contribute tab and populate form with contribution data
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
itemToEdit.value = item
tabIndex.value = 0
router.push({ params: { tab: 'contribute' } })
}