diff --git a/CHANGELOG.md b/CHANGELOG.md index 877d8bcef..f849cc763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Please document your changes in this format: ## [Unreleased] ### Added +- switch to vue-query for offers @nicksellen #2560 - password-less ICS subscription links, to support Google Calender #2555 @tiltec - Korean and Greek translations diff --git a/package.json b/package.json index ef5e30c7a..a1dbdf077 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "markdown-it-emoji": "^2.0.2", "markdown-it-link-attributes": "^4.0.0", "markdown-it-regexp": "^0.4.0", + "mitt": "^3.0.0", "quasar": "^2.7.5", "reconnecting-websocket": "^4.4.0", "twemoji": "^14.0.2", @@ -74,7 +75,9 @@ "vue-croppa": "^1.3.8", "vue-i18n": "^9.1.10", "vue-mention": "^2.0.0-alpha.3", + "vue-query": "^2.0.0-beta.1", "vue-router": "4.1.2", + "vue-router-mock": "^0.1.8", "vuex": "^4.0.2", "vuex-persistedstate": "^4.1.0", "vuex-router-sync": "^5.0.0" @@ -83,6 +86,7 @@ "@babel/core": "^7.18.6", "@babel/eslint-parser": "^7.18.2", "@babel/plugin-transform-modules-commonjs": "^7.18.6", + "@faker-js/faker": "^7.3.0", "@mapbox/appropriate-images": "^5.0.0", "@octokit/rest": "^19.0.3", "@quasar/app-webpack": "^3.5.7", @@ -133,6 +137,7 @@ "keep-a-changelog": "^2.1.0", "lodash": "^4.17.21", "lolex": "^6.0.0", + "path-to-regexp": "^6.2.1", "postcss": "^8.4.14", "postcss-html": "^1.5.0", "postcss-syntax": "^0.36.2", diff --git a/quasar.conf.js b/quasar.conf.js index 175318f8d..d7db37d81 100644 --- a/quasar.conf.js +++ b/quasar.conf.js @@ -68,6 +68,7 @@ module.exports = configure(function (ctx) { // https://quasar.dev/quasar-cli/boot-files boot: [ 'compat', + 'vue-query', 'loglevel', 'pwa', 'helloDeveloper', diff --git a/src/App.vue b/src/App.vue index 9d1b73b36..6dff243f1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -15,10 +15,18 @@ */ import LoadingProgress from '@/topbar/components/LoadingProgress' +import { useClearDataOnLogout } from '@/utils/composables' +import { useOffersUpdater } from '@/offers/queries' + export default { components: { LoadingProgress, }, + setup () { + // Global kind of things can be registered here + useOffersUpdater() + useClearDataOnLogout() + }, computed: { hasView () { const firstMatched = this.$route.matched.length > 0 && this.$route.matched[0] diff --git a/src/__snapshots__/storyshots.spec.js.snap b/src/__snapshots__/storyshots.spec.js.snap index b6fd885a5..67e14aecf 100644 --- a/src/__snapshots__/storyshots.spec.js.snap +++ b/src/__snapshots__/storyshots.spec.js.snap @@ -9493,7 +9493,7 @@ exports[`Storyshots Notifications activity_disabled 1`] = `
-
Pickup on Sunday, December 24 at 12:00 PM was disabled
+
Pickup on Sunday, December 24, 12:00 PM was disabled
less than a minute ago
· Group 30 · Place 2 @@ -9507,7 +9507,7 @@ exports[`Storyshots Notifications activity_enabled 1`] = `
-
Pickup on Sunday, December 24 at 12:00 PM was enabled again
+
Pickup on Sunday, December 24, 12:00 PM was enabled again
less than a minute ago
· Group 30 · Place 2 @@ -9521,7 +9521,7 @@ exports[`Storyshots Notifications activity_moved 1`] = `
-
Pickup was moved to Sunday, December 24 at 12:00 PM
+
Pickup was moved to Sunday, December 24, 12:00 PM
less than a minute ago
· Group 30 · Place 2 diff --git a/src/authuser/datastore/auth.spec.js b/src/authuser/datastore/auth.spec.js index 1e8b8e568..81964d6aa 100644 --- a/src/authuser/datastore/auth.spec.js +++ b/src/authuser/datastore/auth.spec.js @@ -1,11 +1,10 @@ -const routerMocks = require('>/routerMocks').default const mockStatus = jest.fn() const mockLogin = jest.fn() -const mockRouterPush = jest.fn(routerMocks.$router.push) -jest.mock('@/router', () => ({ push: mockRouterPush })) jest.mock('@/authuser/api/auth', () => ({ login: mockLogin })) jest.mock('@/authuser/api/authUser', () => ({ get: mockStatus })) +import { router } from '>/routerMocks' + import { createDatastore, createValidationError, throws } from '>/helpers' describe('auth', () => { @@ -43,7 +42,7 @@ describe('auth', () => { expect(datastore.getters['auth/loginStatus'].validationErrors).toEqual({}) expect(datastore.getters['auth/isLoggedIn']).toBe(true) expect(datastore.getters['auth/user']).toBeDefined() - expect(mockRouterPush).toBeCalledWith('/') + expect(router.push).toBeCalledWith('/') }) it('can update user', () => { diff --git a/src/base/routes/main.js b/src/base/routes/main.js index fc18a6352..d1a500758 100644 --- a/src/base/routes/main.js +++ b/src/base/routes/main.js @@ -274,7 +274,6 @@ export default [ breadcrumbs: [ { translation: 'GROUP.OFFERS', route: { name: 'groupOffers' } }, ], - afterLeave: 'offers/clear', }, components: { default: GroupOffers, diff --git a/src/boot/socket.js b/src/boot/socket.js index 01455ad03..05ab1f78f 100644 --- a/src/boot/socket.js +++ b/src/boot/socket.js @@ -1,5 +1,6 @@ import ReconnectingWebsocket from 'reconnecting-websocket' import { debounce, AppVisibility } from 'quasar' +import mitt from 'mitt' import log from '@/utils/log' import auth from '@/authuser/api/auth' @@ -19,6 +20,9 @@ import { convert as convertOffer } from '@/offers/api/offers' import { convert as convertGroup } from '@/group/api/groups' import { convert as convertNotification, convertMeta as convertNotificationMeta } from '@/notifications/api/notifications' +// Global event bus for websocket events +export const socketEvents = mitt() + export default async function ({ store: datastore }) { let WEBSOCKET_ENDPOINT @@ -114,7 +118,9 @@ export default async function ({ store: datastore }) { clearTimeout(pingTimeout) } - receiveMessage(data) + if (data.topic) { + receiveMessage(data) + } }) // reconnect when browser tells us the connection is back @@ -155,12 +161,40 @@ export default async function ({ store: datastore }) { }, } + function convertPayload (topic, payload) { + switch (topic) { + case 'applications:update': return convertApplication(payload) + case 'conversations:message': return convertMessage(payload) + case 'conversations:conversation': return convertConversation(payload) + case 'conversations:meta': return convertConversationMeta(payload) + case 'community_feed:meta': return convertCommunityFeedMeta(payload) + case 'groups:group_detail': return convertGroup(payload) + case 'invitations:invitation': return convertInvitation(payload) + case 'issues:issue': return convertIssue(payload) + case 'activities:activity': return convertActivity(payload) + case 'activities:activity_deleted': return convertActivity(payload) + case 'activities:series': return convertSeries(payload) + case 'activities:series_deleted': return convertSeries(payload) + case 'offers:offer': return convertOffer(payload) + case 'offers:offer_deleted': return convertOffer(payload) + case 'feedback:feedback': return convertFeedback(payload) + case 'history:history': return convertHistory(payload) + case 'notifications:notification': return convertNotification(payload) + case 'notifications:meta': return convertNotificationMeta(payload) + default: return payload + } + } + function receiveMessage ({ topic, payload }) { + payload = convertPayload(topic, camelizeKeys(payload)) + + socketEvents.emit(topic, payload) + if (topic === 'applications:update') { - datastore.commit('applications/update', [convertApplication(camelizeKeys(payload))]) + datastore.commit('applications/update', [payload]) } else if (topic === 'conversations:message') { - const message = convertMessage(camelizeKeys(payload)) + const message = payload if (message.thread) { datastore.dispatch('currentThread/receiveMessage', message) datastore.dispatch('latestMessages/updateThreadsAndRelated', { messages: [message] }) @@ -175,15 +209,15 @@ export default async function ({ store: datastore }) { } } else if (topic === 'conversations:conversation') { - const conversation = convertConversation(camelizeKeys(payload)) + const conversation = payload datastore.dispatch('conversations/updateConversation', conversation) datastore.dispatch('latestMessages/updateConversationsAndRelated', { conversations: [conversation] }) } else if (topic === 'conversations:meta') { - datastore.commit('latestMessages/setEntryMeta', convertConversationMeta(camelizeKeys(payload))) + datastore.commit('latestMessages/setEntryMeta', payload) } else if (topic === 'community_feed:meta') { - datastore.commit('communityFeed/setMeta', convertCommunityFeedMeta(camelizeKeys(payload))) + datastore.commit('communityFeed/setMeta', payload) } else if (topic === 'conversations:leave') { // refresh latest messages @@ -193,10 +227,10 @@ export default async function ({ store: datastore }) { } } else if (topic === 'groups:group_detail') { - datastore.dispatch('currentGroup/maybeUpdate', convertGroup(camelizeKeys(payload))) + datastore.dispatch('currentGroup/maybeUpdate', payload) } else if (topic === 'groups:group_preview') { - datastore.commit('groups/update', [camelizeKeys(payload)]) + datastore.commit('groups/update', [payload]) } else if (topic === 'groups:user_joined') { datastore.dispatch('users/fetch', null, { root: true }) @@ -205,52 +239,47 @@ export default async function ({ store: datastore }) { datastore.dispatch('users/fetch', null, { root: true }) } else if (topic === 'invitations:invitation') { - datastore.commit('invitations/update', [convertInvitation(camelizeKeys(payload))]) + datastore.commit('invitations/update', [payload]) } else if (topic === 'invitations:invitation_accept') { // delete invitation from list until there is a better way to display it datastore.commit('invitations/delete', payload.id) } else if (topic === 'issues:issue') { - datastore.commit('issues/update', [convertIssue(camelizeKeys(payload))]) + datastore.commit('issues/update', [payload]) } else if (topic === 'places:place') { - datastore.dispatch('places/update', [camelizeKeys(payload)]) + datastore.dispatch('places/update', [payload]) } else if (topic === 'activities:activity') { - datastore.commit('activities/update', [convertActivity(camelizeKeys(payload))]) + datastore.commit('activities/update', [payload]) } else if (topic === 'activities:activity_deleted') { - datastore.commit('activities/delete', convertActivity(camelizeKeys(payload)).id) + datastore.commit('activities/delete', payload.id) } else if (topic === 'activities:series') { - datastore.commit('activitySeries/update', [convertSeries(camelizeKeys(payload))]) + datastore.commit('activitySeries/update', [payload]) } else if (topic === 'activities:series_deleted') { - datastore.commit('activitySeries/delete', convertSeries(camelizeKeys(payload)).id) + datastore.commit('activitySeries/delete', payload.id) } else if (topic === 'activities:type') { - datastore.commit('activityTypes/update', [camelizeKeys(payload)]) + datastore.commit('activityTypes/update', [payload]) } else if (topic === 'activities:type_deleted') { datastore.commit('activityTypes/delete', payload.id) } else if (topic === 'offers:offer') { - const offer = convertOffer(camelizeKeys(payload)) - datastore.commit('offers/update', [offer]) - datastore.commit('latestMessages/updateRelated', { type: 'offer', items: [offer] }) - datastore.commit('currentOffer/update', offer) + datastore.commit('latestMessages/updateRelated', { type: 'offer', items: [payload] }) } else if (topic === 'offers:offer_deleted') { - datastore.commit('offers/delete', payload.id) datastore.commit('latestMessages/deleteRelated', { type: 'offer', ids: [payload.id] }) - datastore.commit('currentOffer/delete', payload.id) } else if (topic === 'feedback:feedback') { - datastore.dispatch('feedback/updateOne', convertFeedback(camelizeKeys(payload))) + datastore.dispatch('feedback/updateOne', payload) } else if (topic === 'auth:user') { - const user = camelizeKeys(payload) + const user = payload datastore.commit('auth/setUser', user) datastore.commit('users/update', [user]) datastore.dispatch('users/refreshProfile', user) @@ -259,24 +288,24 @@ export default async function ({ store: datastore }) { datastore.dispatch('auth/refresh') } else if (topic === 'users:user') { - const user = camelizeKeys(payload) + const user = payload datastore.commit('users/update', [user]) datastore.dispatch('users/refreshProfile', user) } else if (topic === 'history:history') { - datastore.commit('history/update', [convertHistory(camelizeKeys(payload))]) + datastore.commit('history/update', [payload]) } else if (topic === 'notifications:notification') { - datastore.commit('notifications/update', [convertNotification(camelizeKeys(payload))]) + datastore.commit('notifications/update', [payload]) } else if (topic === 'notifications:notification_deleted') { datastore.commit('notifications/delete', payload.id) } else if (topic === 'notifications:meta') { - datastore.commit('notifications/setEntryMeta', convertNotificationMeta(camelizeKeys(payload))) + datastore.commit('notifications/setEntryMeta', payload) } else if (topic === 'status') { - datastore.commit('status/update', camelizeKeys(payload)) + datastore.commit('status/update', payload) } } diff --git a/src/boot/vue-query.js b/src/boot/vue-query.js new file mode 100644 index 000000000..8a0632392 --- /dev/null +++ b/src/boot/vue-query.js @@ -0,0 +1,5 @@ +import { VueQueryPlugin } from 'vue-query' + +export default ({ app, store }) => { + app.use(VueQueryPlugin) +} diff --git a/src/group/datastore/currentGroup.js b/src/group/datastore/currentGroup.js index 269812245..f809dc960 100644 --- a/src/group/datastore/currentGroup.js +++ b/src/group/datastore/currentGroup.js @@ -1,5 +1,11 @@ import groups from '@/group/api/groups' -import { withMeta, createMetaModule, withPrefixedIdMeta, metaStatusesWithId, createRouteRedirect } from '@/utils/datastore/helpers' +import { + createMetaModule, + createRouteRedirect, + metaStatusesWithId, + withMeta, + withPrefixedIdMeta, +} from '@/utils/datastore/helpers' import { extend } from 'quasar' import i18n from '@/base/i18n' import { messages as loadMessages } from '@/locales/index' diff --git a/src/group/queries.js b/src/group/queries.js new file mode 100644 index 000000000..7578cfcb8 --- /dev/null +++ b/src/group/queries.js @@ -0,0 +1,7 @@ +import { useStore } from 'vuex' +import { computed } from 'vue' + +export function useCurrentGroupIdRef () { + const store = useStore() + return computed(() => store.getters['currentGroup/id']) +} diff --git a/src/groupInfo/datastore/groups.spec.js b/src/groupInfo/datastore/groups.spec.js index a27abb4ac..dd4a49409 100644 --- a/src/groupInfo/datastore/groups.spec.js +++ b/src/groupInfo/datastore/groups.spec.js @@ -12,13 +12,7 @@ jest.mock('@/groupInfo/api/groupsInfo', () => ({ list: mockFetchGroupsPreview, })) -const routerMocks = require('>/routerMocks').default -const mockRouterPush = jest.fn(routerMocks.$router.push) -const mockRouterReplace = jest.fn(routerMocks.$router.replace) -jest.mock('@/router', () => ({ - push: mockRouterPush, - replace: mockRouterReplace, -})) +import { router } from '>/routerMocks' import { createDatastore, createValidationError, throws, statusMocks } from '>/helpers' import { enrichGroup } from '>/datastoreHelpers' @@ -131,7 +125,7 @@ describe('groups', () => { mockJoin.mockReturnValueOnce({}) expect(datastore.getters['groups/mineWithApplications'].map(e => e.id)).toEqual([group2.id, group3.id]) await datastore.dispatch('groups/join', group1.id) - expect(mockRouterPush).toBeCalledWith({ name: 'group', params: { groupId: group1.id } }) + expect(router.push).toBeCalledWith({ name: 'group', params: { groupId: group1.id } }) expect(mockJoin).toBeCalledWith(group1.id) }) diff --git a/src/messages/components/LatestConversations.vue b/src/messages/components/LatestConversations.vue index e49ea2d0b..721a068fc 100644 --- a/src/messages/components/LatestConversations.vue +++ b/src/messages/components/LatestConversations.vue @@ -118,7 +118,7 @@ export default { case 'issue': return this.$router.push({ name: 'issueChat', params: { groupId: target.group.id, issueId: target.id } }).catch(() => {}) case 'offer': return this.$router.push({ name: 'offerDetail', - params: { groupId: target.group.id, offerId: target.id }, + params: { groupId: target.group, offerId: target.id }, query: this.$route.query, }).catch(() => {}) } diff --git a/src/messages/datastore/latestMessages.js b/src/messages/datastore/latestMessages.js index bd87ce475..bffa84662 100644 --- a/src/messages/datastore/latestMessages.js +++ b/src/messages/datastore/latestMessages.js @@ -77,13 +77,7 @@ export default { fetchingPastThreads: (state, getters) => getters['meta/status']('fetchPastThreads').pending, fetchInitialPending: (state, getters) => getters['meta/status']('fetchInitial').pending, getRelated: (state, getters, rootState, rootGetters) => (type, id) => { - const related = state.related[type] && state.related[type][id] - if (!related) return - switch (type) { - case 'offer': return rootGetters['offers/enrich'](related) - default: - return related - } + return state.related[type] && state.related[type][id] }, }, actions: { diff --git a/src/offers/api/offers.mock.js b/src/offers/api/offers.mock.js new file mode 100644 index 000000000..0674ec6f9 --- /dev/null +++ b/src/offers/api/offers.mock.js @@ -0,0 +1,30 @@ +import { faker } from '@faker-js/faker' +import { createCursorPaginatedBackend, createGetByIdBackend } from '>/mockAxios' + +function sample (items) { + return items[Math.floor(Math.realRandom() * items.length)] +} + +let nextOfferId = 1 +export function createOffer (params) { + return { + id: ++nextOfferId, + name: faker.random.words(5), + description: faker.lorem.paragraphs(2), + status: sample(['active', 'archived']), + created_at: faker.date.past(), + group: 1, + images: [], + ...params, + } +} + +export function createMockOffersBackend (offers, options = {}) { + createCursorPaginatedBackend('/api/offers/', offers, ({ params }) => { + const status = params.status + const group = parseInt(params.group || '1') + return offer => offer.status === status && offer.group === group + }, options) + + createGetByIdBackend('/api/offers/:id/', offers) +} diff --git a/src/offers/components/OfferDetailBody.vue b/src/offers/components/OfferDetailBody.vue index 8b5d140b2..8237072ba 100644 --- a/src/offers/components/OfferDetailBody.vue +++ b/src/offers/components/OfferDetailBody.vue @@ -35,7 +35,7 @@ {{ offer.status }}
- +
@@ -76,7 +79,9 @@ import ChatConversation from '@/messages/components/ChatConversation' import Markdown from '@/utils/components/Markdown' import KSpinner from '@/utils/components/KSpinner' import { QBtn, QBtnDropdown, QCarousel, QCarouselSlide } from 'quasar' -import { DEFAULT_STATUS } from '@/offers/datastore/offers' +import { DEFAULT_STATUS, useCurrentOfferQuery } from '@/offers/queries' +import { useCurrentUserIdRef } from '@/users/queries' +import { useArchiveOfferMutation } from '@/offers/mutations' export default { components: { @@ -94,6 +99,15 @@ export default { default: false, }, }, + setup () { + const { mutate: archive } = useArchiveOfferMutation() + const { offer } = useCurrentOfferQuery() + return { + archive, + offer, + currentUserId: useCurrentUserIdRef(), + } + }, data () { return { selectedImageIndex: 0, @@ -101,11 +115,13 @@ export default { }, computed: { ...mapGetters({ - offer: 'currentOffer/value', conversation: 'currentOffer/conversation', away: 'presence/toggle/away', currentUser: 'auth/user', }), + canEdit () { + return this.offer.user === this.currentUserId + }, conversationWithReversedMessages () { return { ...this.conversation, @@ -140,7 +156,6 @@ export default { toggleReaction: 'conversations/toggleReaction', fetchPast: 'conversations/fetchPast', saveConversation: 'conversations/maybeSave', - archive: 'offers/archive', }), }, } diff --git a/src/offers/components/OfferDetailHeader.vue b/src/offers/components/OfferDetailHeader.vue index 51f4f37ff..14a7fcc1f 100644 --- a/src/offers/components/OfferDetailHeader.vue +++ b/src/offers/components/OfferDetailHeader.vue @@ -7,7 +7,7 @@
{{ offer.name }}
diff --git a/src/offers/pages/OfferCreate.vue b/src/offers/pages/OfferCreate.vue index 83c51c8f9..403cafde9 100644 --- a/src/offers/pages/OfferCreate.vue +++ b/src/offers/pages/OfferCreate.vue @@ -1,17 +1,18 @@ - diff --git a/src/offers/pages/OfferEdit.vue b/src/offers/pages/OfferEdit.vue index 9b678bc80..80dfa3d6f 100644 --- a/src/offers/pages/OfferEdit.vue +++ b/src/offers/pages/OfferEdit.vue @@ -1,19 +1,23 @@ - diff --git a/src/offers/queries.js b/src/offers/queries.js new file mode 100644 index 000000000..41acb6511 --- /dev/null +++ b/src/offers/queries.js @@ -0,0 +1,112 @@ +import { useInfiniteQuery, useQuery, useQueryClient } from 'vue-query' +import { computed, unref } from 'vue' + +import api from './api/offers' + +import { useRoute } from 'vue-router' +import { useSocketEvents } from '@/utils/composables' +import { extractCursor } from '@/utils/queryHelpers' +import { isMutating } from '@/offers/mutations' + +export const QUERY_KEY_BASE = 'offers' +export const queryKeyOfferList = (group, status) => [QUERY_KEY_BASE, 'list', group, status].filter(Boolean) +export const queryKeyOfferDetail = id => [QUERY_KEY_BASE, 'detail', id].filter(Boolean) + +export const DEFAULT_STATUS = 'active' + +/** + * Handler for socket updates + */ +export function useOffersUpdater () { + const queryClient = useQueryClient() + const { on } = useSocketEvents() + on( + [ + 'offers:offer', + 'offers:offer_deleted', + ], + // We could do fiddly updates to the data, but simpler to just invalidate the lot + async offer => { + // Invalidate the list + await queryClient.invalidateQueries(queryKeyOfferList()) + + // Only invalidate the detail if we are not currently mutating it + if (!isMutating(offer.id)) { + await queryClient.invalidateQueries(queryKeyOfferDetail()) + } + }, + ) +} + +/** + * Get current offer, based on route + * + * Gives a full query object + */ +export function useCurrentOfferQuery () { + const route = useRoute() + const id = computed(() => route.params.offerId && Number(route.params.offerId)) + return useOfferQuery({ id }) +} + +/** + * Fetch an offer by id + * + * Returns a query object with data also available "offer" key + */ +export function useOfferQuery ({ + id, +}) { + const query = useQuery( + queryKeyOfferDetail(id), + () => api.get(unref(id)), + { + enabled: computed(() => !!unref(id)), + staleTime: Infinity, + }, + ) + return { + ...query, + offer: query.data, + } +} + +/** + * Query offers by group and status + * + * Returns a paginated query object with additional "offers" item with flattened list of all offers + */ +export function useOffersQuery ({ + group, + status = 'active', +}) { + const query = useInfiniteQuery( + queryKeyOfferList(group, status), + ({ pageParam }) => api.list({ + group: unref(group), + status: unref(status), + cursor: pageParam, + }), + { + enabled: computed(() => !!unref(group)), + staleTime: Infinity, + getNextPageParam: page => extractCursor(page.next) || undefined, + select: ({ pages, pageParams }) => ({ + pages: pages.map(page => page.results), + pageParams, + }), + }, + ) + + // Flatten the pages, so we have a single offers array with all the results in + const offers = computed(() => { + const data = unref(query.data) + if (!data) return [] + return data.pages.flat() + }) + + return { + ...query, + offers, + } +} diff --git a/src/offers/queries.spec.js b/src/offers/queries.spec.js new file mode 100644 index 000000000..da30bbf77 --- /dev/null +++ b/src/offers/queries.spec.js @@ -0,0 +1,92 @@ +import { ref } from 'vue' +import { useOfferQuery, useOffersQuery } from '@/offers/queries' +import { flushPromises, mount } from '@vue/test-utils' +import { VueQueryPlugin } from 'vue-query' +import { createOffer, createMockOffersBackend } from '@/offers/api/offers.mock' +import { mockAxios } from '>/mockAxios' +import { camelizeKeys } from '@/utils/utils' + +describe('offer queries', () => { + beforeEach(() => jest.resetModules()) + afterEach(() => mockAxios.reset()) + + describe('useOfferQuery', () => { + it('can switch between offers', async () => { + const offer1 = createOffer() + const offer2 = createOffer() + createMockOffersBackend([offer1, offer2]) + + const id = ref(null) + const wrapper = mount({ + setup: () => useOfferQuery({ id }), + }, { + global: { plugins: [VueQueryPlugin] }, + }) + // undefined as id is not set + await flushPromises() + expect(wrapper.vm.offer).toBeUndefined() + + // switch to offer1 + id.value = offer1.id + await flushPromises() + expect(wrapper.vm.offer).toEqual(camelizeKeys(offer1)) + + // switch to offer2 + id.value = offer2.id + await flushPromises() + expect(wrapper.vm.offer).toEqual(camelizeKeys(offer2)) + + // and back to nothing again! + id.value = null + await flushPromises() + expect(wrapper.vm.offer).toBeUndefined() + }) + }) + + describe('useOffersQuery', () => { + it('can filter and paginate', async () => { + createMockOffersBackend([ + ...Array.from( + { length: 8 }, + () => createOffer({ status: 'active' }), + ), + ...Array.from( + { length: 4 }, + () => createOffer({ status: 'archived' }), + ), + ], { + pageSize: 5, + }) + + const group = ref(null) + const status = ref('active') + const wrapper = mount({ + setup: () => useOffersQuery({ group, status }), + }, { + global: { plugins: [VueQueryPlugin] }, + }) + + await flushPromises() + + // nothing as we have no group set + expect(wrapper.vm.offers).toHaveLength(0) + + group.value = 1 + await flushPromises() + + // First page of entries + expect(wrapper.vm.offers).toHaveLength(5) + expect(wrapper.vm.hasNextPage).toBe(true) + + // Get next page, to be added to existing ones + await wrapper.vm.fetchNextPage() + expect(wrapper.vm.offers).toHaveLength(8) + expect(wrapper.vm.hasNextPage).toBe(false) + + // should have 4 archived entries + status.value = 'archived' + await flushPromises() + expect(wrapper.vm.offers).toHaveLength(4) + }) + }) +}) diff --git a/src/statistics/pages/ActivityHistoryStatistics.vue b/src/statistics/pages/ActivityHistoryStatistics.vue index 6d279d2ae..7e5aa4c21 100644 --- a/src/statistics/pages/ActivityHistoryStatistics.vue +++ b/src/statistics/pages/ActivityHistoryStatistics.vue @@ -1,6 +1,7 @@