fix(SPA): activity list saves pagination too
This commit is contained in:
1 parent
9d9453c1b3
commit
c41e6dbca5
5 files changed
+193
-4
No files matched your search
@@ -1,12 +1,15 @@
|
|||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ApiError } from '@/composables/useApi'
|
import { ApiError } from '@/composables/useApi'
|
||||||
import { useActivity, type PersonOptions } from '@/composables/useActivity'
|
import { useActivity, type PersonOptions } from '@/composables/useActivity'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { ACTIVITY_PAGE_SIZE_KEY, resolvePageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||||
import {
|
import {
|
||||||
activeChips,
|
activeChips,
|
||||||
clearChip,
|
clearChip,
|
||||||
defaultFilters,
|
defaultFilters,
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
|
PAGE_SIZES,
|
||||||
summaryRangeChanged,
|
summaryRangeChanged,
|
||||||
type ActivityFilters,
|
type ActivityFilters,
|
||||||
type ActivityItem,
|
type ActivityItem,
|
||||||
@@ -45,11 +48,20 @@ function delayedFlag() {
|
|||||||
|
|
||||||
export function useActivityPage() {
|
export function useActivityPage() {
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { listActivity, getSummary, listPeople } = useActivity()
|
const { listActivity, getSummary, listPeople } = useActivity()
|
||||||
|
|
||||||
const filters = ref<ActivityFilters>(defaultFilters())
|
const filters = ref<ActivityFilters>(defaultFilters())
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const size = ref(DEFAULT_PAGE_SIZE)
|
const size = ref(
|
||||||
|
resolvePageSize(
|
||||||
|
route.query.size,
|
||||||
|
ACTIVITY_PAGE_SIZE_KEY,
|
||||||
|
[...PAGE_SIZES],
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
),
|
||||||
|
)
|
||||||
const items = ref<ActivityItem[]>([])
|
const items = ref<ActivityItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const listState = ref<ListState>('idle')
|
const listState = ref<ListState>('idle')
|
||||||
@@ -192,9 +204,20 @@ export function useActivityPage() {
|
|||||||
fetchPage(next)
|
fetchPage(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSizeQuery() {
|
||||||
|
const { size: current, ...rest } = route.query
|
||||||
|
const wanted = size.value === DEFAULT_PAGE_SIZE ? undefined : String(size.value)
|
||||||
|
if (current === wanted) return
|
||||||
|
router.replace({ query: wanted === undefined ? rest : { ...rest, size: wanted } })
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSizeQuery()
|
||||||
|
|
||||||
function setSize(next: number) {
|
function setSize(next: number) {
|
||||||
if (next === size.value) return
|
if (next === size.value) return
|
||||||
size.value = next
|
size.value = next
|
||||||
|
storePageSize(ACTIVITY_PAGE_SIZE_KEY, next)
|
||||||
|
syncSizeQuery()
|
||||||
fetchPage(1)
|
fetchPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { readStoredPageSize, storePageSize } from '../pageSizeMemory'
|
import { readStoredPageSize, resolvePageSize, storePageSize } from '../pageSizeMemory'
|
||||||
|
|
||||||
const KEY = 'renewcms-page-size-articles'
|
const KEY = 'renewcms-page-size-articles'
|
||||||
const SIZES = [5, 25, 50, 100]
|
const SIZES = [5, 25, 50, 100]
|
||||||
@@ -53,3 +53,31 @@ describe('pageSizeMemory', () => {
|
|||||||
expect(() => storePageSize(KEY, 50)).not.toThrow()
|
expect(() => storePageSize(KEY, 50)).not.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('resolvePageSize', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the stored size when the query has none', () => {
|
||||||
|
localStorage.setItem(KEY, '5')
|
||||||
|
expect(resolvePageSize(undefined, KEY, SIZES, 25)).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back when nothing valid is stored', () => {
|
||||||
|
expect(resolvePageSize(undefined, KEY, SIZES, 25)).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets a valid query size win over the stored one', () => {
|
||||||
|
localStorage.setItem(KEY, '5')
|
||||||
|
expect(resolvePageSize('100', KEY, SIZES, 25)).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['10', '', 'abc', ['5']])(
|
||||||
|
'falls back to the default for %j, ignoring storage',
|
||||||
|
(raw) => {
|
||||||
|
localStorage.setItem(KEY, '5')
|
||||||
|
expect(resolvePageSize(raw, KEY, SIZES, 25)).toBe(25)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const ARTICLES_PAGE_SIZE_KEY = 'renewcms-page-size-articles'
|
export const ARTICLES_PAGE_SIZE_KEY = 'renewcms-page-size-articles'
|
||||||
|
export const ACTIVITY_PAGE_SIZE_KEY = 'renewcms-page-size-activity'
|
||||||
|
|
||||||
export function readStoredPageSize(key: string, validSizes: number[]) {
|
export function readStoredPageSize(key: string, validSizes: number[]) {
|
||||||
try {
|
try {
|
||||||
@@ -16,3 +17,10 @@ export function storePageSize(key: string, size: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePageSize(raw: unknown, key: string, validSizes: number[], fallback: number) {
|
||||||
|
if (raw === undefined) {
|
||||||
|
return readStoredPageSize(key, validSizes) ?? fallback
|
||||||
|
}
|
||||||
|
return validSizes.find((size) => String(size) === raw) ?? fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import router from '@/router'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { initializeLocale } from '@/i18n'
|
||||||
|
import ActivityPager from '@/components/ActivityPager.vue'
|
||||||
|
import {
|
||||||
|
harness,
|
||||||
|
lastListQuery,
|
||||||
|
mountActivity,
|
||||||
|
queryObject,
|
||||||
|
unmountActivity,
|
||||||
|
} from './activityHarness'
|
||||||
|
import { activityItems, pageOf } from './activityFixtures'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||||
|
return { ...actual, useToast: () => ({ add: vi.fn() }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const KEY = 'renewcms-page-size-activity'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
initializeLocale()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
unmountActivity()
|
||||||
|
vi.mocked(useApi).mockReset()
|
||||||
|
await router.push('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
function list(query: URLSearchParams) {
|
||||||
|
return pageOf(activityItems, {
|
||||||
|
page: Number(query.get('page')),
|
||||||
|
size: Number(query.get('size')),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseSize(size: number) {
|
||||||
|
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', size)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function url() {
|
||||||
|
return router.currentRoute.value.fullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Activity list remembers the chosen page size', () => {
|
||||||
|
it('stores the size, shows it in the URL and restores it on the next visit', async () => {
|
||||||
|
await mountActivity({ list })
|
||||||
|
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||||
|
expect(url()).toBe('/activity')
|
||||||
|
|
||||||
|
await chooseSize(50)
|
||||||
|
expect(localStorage.getItem(KEY)).toBe('50')
|
||||||
|
expect(queryObject(lastListQuery())).toMatchObject({ page: '1', size: '50' })
|
||||||
|
expect(url()).toBe('/activity?size=50')
|
||||||
|
|
||||||
|
unmountActivity()
|
||||||
|
await mountActivity({ list })
|
||||||
|
expect(queryObject(lastListQuery()).size).toBe('50')
|
||||||
|
expect(harness.wrapper!.findComponent(ActivityPager).props('size')).toBe(50)
|
||||||
|
expect(url()).toBe('/activity?size=50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops size from the URL and stores 25 when returning to the default', async () => {
|
||||||
|
localStorage.setItem(KEY, '100')
|
||||||
|
await mountActivity({ list })
|
||||||
|
expect(url()).toBe('/activity?size=100')
|
||||||
|
await chooseSize(25)
|
||||||
|
expect(localStorage.getItem(KEY)).toBe('25')
|
||||||
|
expect(url()).toBe('/activity')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets a valid size in the URL win without storing it', async () => {
|
||||||
|
localStorage.setItem(KEY, '50')
|
||||||
|
await mountActivity({ list, path: '/activity?size=100' })
|
||||||
|
expect(queryObject(lastListQuery()).size).toBe('100')
|
||||||
|
expect(localStorage.getItem(KEY)).toBe('50')
|
||||||
|
expect(url()).toBe('/activity?size=100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to 25 for an invalid size in the URL, ignoring the stored one', async () => {
|
||||||
|
localStorage.setItem(KEY, '50')
|
||||||
|
await mountActivity({ list, path: '/activity?size=10' })
|
||||||
|
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||||
|
expect(localStorage.getItem(KEY)).toBe('50')
|
||||||
|
expect(url()).toBe('/activity')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps other query parameters when the size changes', async () => {
|
||||||
|
await mountActivity({ list, path: '/activity?foo=bar' })
|
||||||
|
await chooseSize(50)
|
||||||
|
expect(url()).toBe('/activity?foo=bar&size=50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['10', 'abc', '5', '0', '-25', '25.5', ''])(
|
||||||
|
'ignores the stored value %j and uses 25',
|
||||||
|
async (stored) => {
|
||||||
|
localStorage.setItem(KEY, stored)
|
||||||
|
await mountActivity({ list })
|
||||||
|
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||||
|
expect(url()).toBe('/activity')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('keeps working when storage throws on read or write', async () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked')
|
||||||
|
})
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('quota')
|
||||||
|
})
|
||||||
|
await mountActivity({ list })
|
||||||
|
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||||
|
|
||||||
|
await chooseSize(100)
|
||||||
|
expect(queryObject(lastListQuery())).toMatchObject({ page: '1', size: '100' })
|
||||||
|
expect(url()).toBe('/activity?size=100')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,6 +28,7 @@ export interface MountOptions extends ActivityApiMock {
|
|||||||
role?: string
|
role?: string
|
||||||
desktop?: boolean
|
desktop?: boolean
|
||||||
verified?: boolean
|
verified?: boolean
|
||||||
|
path?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordedCall {
|
export interface RecordedCall {
|
||||||
@@ -69,7 +70,7 @@ export function callsTo(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function mountActivity(options: MountOptions = {}) {
|
export async function mountActivity(options: MountOptions = {}) {
|
||||||
const { desktop = true, verified = true } = options
|
const { desktop = true, verified = true, path = '/activity' } = options
|
||||||
const role = 'role' in options ? options.role : 'owner'
|
const role = 'role' in options ? options.role : 'owner'
|
||||||
const api: ActivityApiMock = {
|
const api: ActivityApiMock = {
|
||||||
list: () => pageOf(activityItems),
|
list: () => pageOf(activityItems),
|
||||||
@@ -126,7 +127,7 @@ export async function mountActivity(options: MountOptions = {}) {
|
|||||||
requestBlob,
|
requestBlob,
|
||||||
} as unknown as ReturnType<typeof useApi>)
|
} as unknown as ReturnType<typeof useApi>)
|
||||||
|
|
||||||
await router.push('/activity')
|
await router.push(path)
|
||||||
harness.wrapper = mount(ActivityView, {
|
harness.wrapper = mount(ActivityView, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
global: { plugins: [router, uiPlugin] },
|
global: { plugins: [router, uiPlugin] },
|
||||||
|
|||||||
Reference in new issue
Block a user