decouple all and user contributions for better code readability and testability

This commit is contained in:
einhornimmond 2025-05-15 13:29:45 +02:00
parent 7aeac5690f
commit 813caa1fac
9 changed files with 342 additions and 185 deletions

View File

@ -4,12 +4,29 @@ import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ContributionList from './ContributionList'
import { createRouter, createWebHistory } from 'vue-router'
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: ContributionList,
},
],
})
vi.mock('@/components/Contributions/ContributionListItem.vue', () => ({
default: {
name: 'ContributionListItem',
@ -25,7 +42,7 @@ describe('ContributionList', () => {
let wrapper
const global = {
plugins: [i18n],
plugins: [i18n, router],
mocks: {
$filters: {
GDD: vi.fn((val) => val),
@ -36,9 +53,9 @@ describe('ContributionList', () => {
},
}
const contributionsList = {
const contributions = {
contributionCount: 3,
contributionList: [
listContributions: [
{
id: 0,
date: '07/06/2022',
@ -64,11 +81,13 @@ describe('ContributionList', () => {
}
const propsData = {
contributionCount: 3,
showPagination: true,
pageSize: 25,
allContribution: false,
items: [
emptyText: '',
}
const allContributions = {
contributionCount: 3,
listAllContributions: [
{
id: 0,
date: '07/06/2022',
@ -95,7 +114,7 @@ describe('ContributionList', () => {
const mountWrapper = () => {
return mount(ContributionList, {
props: propsData,
propsData,
global,
})
}
@ -107,10 +126,20 @@ describe('ContributionList', () => {
beforeEach(() => {
vi.mocked(useQuery).mockImplementation((query) => {
if (query === listContributions) {
return { onResult: mockListContributionsQuery, refetch: vi.fn() }
return {
result: contributions,
loading: vi.fn(),
onResult: mockListContributionsQuery,
refetch: vi.fn(),
}
}
if (query === listAllContributions) {
return { onResult: mockListAllContributionsQuery, refetch: vi.fn() }
return {
result: allContributions,
loading: vi.fn(),
onResult: mockListAllContributionsQuery,
refetch: vi.fn(),
}
}
})
@ -152,7 +181,8 @@ describe('ContributionList', () => {
describe('list count greater than page size', () => {
beforeEach(async () => {
await wrapper.setProps({ contributionCount: 33 })
contributions.contributionCount = 33
// await wrapper.setProps({ contributionCount: 33 })
})
it('has pagination buttons', () => {
@ -165,7 +195,8 @@ describe('ContributionList', () => {
window.scrollTo = scrollToMock
beforeEach(async () => {
await wrapper.setProps({ contributionCount: 33 })
contributions.contributionCount = 33
// await wrapper.setProps({ contributionCount: 33 })
await wrapper.findComponent({ name: 'BPagination' }).vm.$emit('update:modelValue', 2)
})

View File

@ -1,7 +1,7 @@
<template>
<div v-if="items.length === 0 && !loading">
<div v-if="currentPage === 1">
{{ emptyText }}
{{ t('contribution.noContributions.myContributions') }}
</div>
<div v-else>
{{ t('contribution.noContributions.emptyPage') }}
@ -13,7 +13,6 @@
<contribution-list-item
v-bind="item"
:contribution-id="item.id"
:all-contribution="allContribution"
:messages-visible="openMessagesListId === item.id"
@toggle-messages-visible="toggleMessagesVisible(item.id)"
@update-contribution-form="updateContributionForm"
@ -22,98 +21,71 @@
</div>
</div>
</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="updatePage"
<paginator-route-params-page
v-model="currentPage"
:page-size="pageSize"
:total-count="contributionCount"
:loading="loading"
/>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import ContributionListItem from '@/components/Contributions/ContributionListItem.vue'
import { listContributions, listAllContributions } from '@/graphql/contributions.graphql'
import { listContributions } 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'
import CONFIG from '@/config'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import PaginatorRouteParamsPage from '@/components/PaginatorRouteParamsPage.vue'
const props = defineProps({
allContribution: {
type: Boolean,
required: false,
default: false,
},
emptyText: {
type: String,
required: false,
default: '',
},
})
const route = useRoute()
// composables
const { toastError, toastSuccess } = useAppToast()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
// constants
const pageSize = PAGE_SIZE
const pollInterval = CONFIG.AUTO_POLL_INTERVAL || undefined
// events
const emit = defineEmits(['update-contribution-form'])
// refs
const currentPage = computed(() => Number(route.params.page) || 1)
const currentPage = ref(1)
// computed
const openMessagesListId = ref(null)
// queries
const { result, loading, refetch, onResult } = useQuery(
props.allContribution ? listAllContributions : listContributions,
() => ({
listContributions,
{
pagination: {
currentPage: currentPage.value,
pageSize,
order: 'DESC',
},
messagePagination: !props.allContribution
? {
currentPage: 1,
pageSize: 10,
order: 'ASC',
}
: undefined,
}),
{ fetchPolicy: 'cache-and-network', pollInterval },
messagePagination: {
currentPage: 1,
pageSize: 10,
order: 'ASC',
},
},
{
fetchPolicy: 'cache-and-network',
pollInterval,
},
)
// 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
return result.value?.listContributions.contributionCount || 0
})
const items = computed(() => {
return [...(contributionListResult.value?.contributionList || [])]
})
const isPaginationVisible = computed(() => {
return (contributionCount.value > pageSize || currentPage.value > 1) && !loading.value
return [...(result.value?.listContributions.contributionList || [])]
})
// callbacks
onResult(({ data }) => {
onResult(({ _data }) => {
nextTick(() => {
if (!route.hash) {
return
@ -136,7 +108,4 @@ const toggleMessagesVisible = (contributionId) => {
const updateContributionForm = (item) => {
emit('update-contribution-form', { item, page: currentPage.value })
}
const updatePage = (page) => {
router.push({ params: { page } })
}
</script>

View File

@ -0,0 +1,61 @@
<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="all-contribution-list">
<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'
// constants
const pollInterval = CONFIG.AUTO_POLL_INTERVAL || undefined
const pageSize = PAGE_SIZE
// computed
const currentPage = ref(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,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

@ -2,26 +2,15 @@
<div>
<div
class="contribution-list-item bg-white app-box-shadow gradido-border-radius pt-3 px-3"
:class="localStatus === 'IN_PROGRESS' && !allContribution ? 'pulse border border-205' : ''"
:class="localStatus === 'IN_PROGRESS' ? 'pulse border border-205' : ''"
>
<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"
/>
<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="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>
@ -31,7 +20,7 @@
{{ $t('moderatorChangedMemo') }}
</div>
<div
v-if="localStatus === 'IN_PROGRESS' && !allContribution"
v-if="localStatus === 'IN_PROGRESS'"
class="text-danger pointer hover-font-bold"
@click="emit('toggle-messages-visible')"
>
@ -42,10 +31,6 @@
<div class="small">
{{ $t('creation') }} {{ $t('(') }}{{ hours }} {{ $t('h') }}{{ $t(')') }}
</div>
<div v-if="localStatus === 'DENIED' && allContribution" class="fw-bold">
<variant-icon icon="x-circle" variant="danger" />
{{ $t('contribution.alert.denied') }}
</div>
<div v-if="localStatus === 'DELETED'" class="small">
{{ $t('contribution.deleted') }}
</div>
@ -57,17 +42,10 @@
</div>
</BCol>
</BRow>
<BRow
v-if="
(!['CONFIRMED', 'DELETED'].includes(localStatus) && !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(localStatus) && !allContribution && !moderatorId
"
v-if="!['CONFIRMED', 'DELETED'].includes(localStatus) && !moderatorId"
class="test-delete-contribution pointer me-3"
@click="processDeleteContribution({ id })"
>
@ -78,9 +56,7 @@
</BCol>
<BCol cols="3" class="text-center">
<div
v-if="
!['CONFIRMED', 'DELETED'].includes(localStatus) && !allContribution && !moderatorId
"
v-if="!['CONFIRMED', 'DELETED'].includes(localStatus) && !moderatorId"
class="test-edit-contribution pointer me-3"
@click="
$emit('update-contribution-form', {
@ -111,7 +87,7 @@
v-if="messagesCount > 0"
:messages="localMessages"
:status="localStatus"
:contribution-id="contributionId"
:contribution-id="id"
@close-messages-list="emit('toggle-messages-visible')"
@add-contribution-message="addContributionMessage"
/>
@ -128,9 +104,9 @@ import ContributionMessagesList from '@/components/ContributionMessages/Contribu
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
import { useMutation } from '@vue/apollo-composable'
import AppAvatar from '@/components/AppAvatar.vue'
import { GDD_PER_HOUR } from '../../constants'
import { deleteContribution } from '@/graphql/contributions.graphql'
import { useContributionStatus } from '@/composables/useContributionStatus'
const props = defineProps({
id: {
@ -147,36 +123,9 @@ const props = defineProps({
required: false,
default: () => [],
},
user: {
type: Object,
required: false,
},
createdAt: {
type: String,
},
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,
@ -190,15 +139,6 @@ const props = defineProps({
type: Number,
required: false,
},
contributionId: {
type: Number,
required: true,
},
allContribution: {
type: Boolean,
required: false,
default: false,
},
moderatorId: {
type: Number,
required: false,
@ -213,11 +153,14 @@ const props = defineProps({
const { toastError, toastSuccess } = useAppToast()
const { t } = useI18n()
const { getVariant, getIcon } = useContributionStatus()
const { mutate: deleteContributionMutation } = useMutation(deleteContribution)
const localMessages = ref([])
const localStatus = ref(props.contributionStatus)
const variant = computed(() => getVariant(props.contributionStatus))
const icon = computed(() => getIcon(props.contributionStatus))
// if parent reload messages, update local messages copy
watch(
@ -237,32 +180,6 @@ watch(
},
)
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(() => {
return (statusMapping[localStatus.value] || statusMapping.default).variant
})
const icon = computed(() => {
return (statusMapping[localStatus.value] || statusMapping.default).icon
})
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)))
async function processDeleteContribution(item) {

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

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

@ -35,12 +35,17 @@ query listContributions ($pagination: Paginated!, $messagePagination: Paginated!
listContributions(pagination: $pagination, messagePagination: $messagePagination) {
contributionCount
contributionList {
...contributionFields
deletedAt
moderatorId
id
amount
memo
contributionDate
contributionStatus
messagesCount
messages(pagination: $messagePagination) {
...contributionMessageFields
}
updatedBy
moderatorId
}
}
}
@ -49,10 +54,14 @@ query listAllContributions ($pagination: Paginated!) {
listAllContributions(pagination: $pagination) {
contributionCount
contributionList {
amount
memo
user {
...userFields
}
...contributionFields
}
contributionDate
updatedBy
contributionStatus
}
}
}

View File

@ -11,16 +11,10 @@
<contribution-create v-else />
</BTab>
<BTab no-body lazy>
<contribution-list
:empty-text="$t('contribution.noContributions.myContributions')"
@update-contribution-form="handleUpdateContributionForm"
/>
<contribution-list @update-contribution-form="handleUpdateContributionForm" />
</BTab>
<BTab no-body lazy>
<contribution-list
:empty-text="$t('contribution.noContributions.allContributions')"
:all-contribution="true"
/>
<contribution-list-all />
</BTab>
</BTabs>
</div>
@ -28,24 +22,23 @@
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuery } from '@vue/apollo-composable'
import OpenCreationsAmount from '@/components/Contributions/OpenCreationsAmount'
import ContributionEdit from '@/components/Contributions/ContributionEdit'
import ContributionCreate from '@/components/Contributions/ContributionCreate'
import ContributionList from '@/components/Contributions/ContributionList'
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 route = useRoute()
const router = useRouter()
const { toastError, toastSuccess, toastInfo } = useAppToast()
const { toastInfo } = useAppToast()
const { t } = useI18n()
const tabIndex = ref(0)