From c41e6dbca5153ef429fe1bcc1a58b7221133cfa8 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 13 Oct 2026 16:36:42 +0200 Subject: [PATCH] fix(SPA): activity list saves pagination too --- web/src/composables/useActivityPage.ts | 25 +++- web/src/lib/__tests__/pageSizeMemory.spec.ts | 30 +++- web/src/lib/pageSizeMemory.ts | 8 ++ .../__tests__/ActivityPageSizeMemory.spec.ts | 129 ++++++++++++++++++ web/src/views/__tests__/activityHarness.ts | 5 +- 5 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 web/src/views/__tests__/ActivityPageSizeMemory.spec.ts diff --git a/web/src/composables/useActivityPage.ts b/web/src/composables/useActivityPage.ts index 317c292..b12ba12 100644 --- a/web/src/composables/useActivityPage.ts +++ b/web/src/composables/useActivityPage.ts @@ -1,12 +1,15 @@ import { computed, onBeforeUnmount, ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' import { ApiError } from '@/composables/useApi' import { useActivity, type PersonOptions } from '@/composables/useActivity' import { useSessionStore } from '@/stores/session' +import { ACTIVITY_PAGE_SIZE_KEY, resolvePageSize, storePageSize } from '@/lib/pageSizeMemory' import { activeChips, clearChip, defaultFilters, DEFAULT_PAGE_SIZE, + PAGE_SIZES, summaryRangeChanged, type ActivityFilters, type ActivityItem, @@ -45,11 +48,20 @@ function delayedFlag() { export function useActivityPage() { const session = useSessionStore() + const route = useRoute() + const router = useRouter() const { listActivity, getSummary, listPeople } = useActivity() const filters = ref(defaultFilters()) 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([]) const total = ref(0) const listState = ref('idle') @@ -192,9 +204,20 @@ export function useActivityPage() { 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) { if (next === size.value) return size.value = next + storePageSize(ACTIVITY_PAGE_SIZE_KEY, next) + syncSizeQuery() fetchPage(1) } diff --git a/web/src/lib/__tests__/pageSizeMemory.spec.ts b/web/src/lib/__tests__/pageSizeMemory.spec.ts index e0c3f71..39c0d8c 100644 --- a/web/src/lib/__tests__/pageSizeMemory.spec.ts +++ b/web/src/lib/__tests__/pageSizeMemory.spec.ts @@ -1,5 +1,5 @@ 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 SIZES = [5, 25, 50, 100] @@ -53,3 +53,31 @@ describe('pageSizeMemory', () => { 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) + }, + ) +}) diff --git a/web/src/lib/pageSizeMemory.ts b/web/src/lib/pageSizeMemory.ts index fa247ad..d13b663 100644 --- a/web/src/lib/pageSizeMemory.ts +++ b/web/src/lib/pageSizeMemory.ts @@ -1,4 +1,5 @@ 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[]) { try { @@ -16,3 +17,10 @@ export function storePageSize(key: string, size: number) { 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 +} diff --git a/web/src/views/__tests__/ActivityPageSizeMemory.spec.ts b/web/src/views/__tests__/ActivityPageSizeMemory.spec.ts new file mode 100644 index 0000000..8d48408 --- /dev/null +++ b/web/src/views/__tests__/ActivityPageSizeMemory.spec.ts @@ -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() + return { ...actual, useApi: vi.fn() } +}) + +vi.mock('@nuxt/ui/composables', async (importOriginal) => { + const actual = await importOriginal() + 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') + }) +}) diff --git a/web/src/views/__tests__/activityHarness.ts b/web/src/views/__tests__/activityHarness.ts index 59c42e3..16e8b67 100644 --- a/web/src/views/__tests__/activityHarness.ts +++ b/web/src/views/__tests__/activityHarness.ts @@ -28,6 +28,7 @@ export interface MountOptions extends ActivityApiMock { role?: string desktop?: boolean verified?: boolean + path?: string } export interface RecordedCall { @@ -69,7 +70,7 @@ export function callsTo(path: string) { } 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 api: ActivityApiMock = { list: () => pageOf(activityItems), @@ -126,7 +127,7 @@ export async function mountActivity(options: MountOptions = {}) { requestBlob, } as unknown as ReturnType) - await router.push('/activity') + await router.push(path) harness.wrapper = mount(ActivityView, { attachTo: document.body, global: { plugins: [router, uiPlugin] },