diff --git a/admin/tests/form/FormView.test.ts b/admin/tests/form/FormView.test.ts index 5c135f2..e6abcbc 100644 --- a/admin/tests/form/FormView.test.ts +++ b/admin/tests/form/FormView.test.ts @@ -5,11 +5,14 @@ import FormView from '../../src/views/FormView.vue' import { recordCrumb } from '../../src/state/useBreadcrumbs' import { toasts } from '../../src/state/useToasts' import { WIDGET_EVENT } from '../../src/components/form/formContext' +import { readListState, writeListState } from '../../src/state/listState' import { clone, extensionFormSchemaFixture, formSchemaFixture, langFixture, + listFixture, + listSchemaFixture, recordFixture, rosterFormSchemaFixture, rosterRecordFixture, @@ -662,3 +665,83 @@ describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => { expect(body.name).toEqual({ en: 'Ada\nLovelace', pl: 'Ada po polsku' }) }) }) + +describe('returning to the list keeps its query (D-01)', () => { + const LIST_ID = 'acme.demo.widgets' + const STORED = { sort: 'code', dir: 'desc' as const, filter: { active: 'true' } } + + /** The form routes plus the list schema and rows, so the form can return to the list. */ + function roundTrip(overrides: Record = {}): Record { + return routes({ + [`GET ${BASE}/schema/list`]: { body: listSchemaFixture }, + [`GET ${BASE}`]: { body: listFixture }, + [`PUT ${RECORD}`]: { body: recordFixture }, + [`DELETE ${RECORD}`]: { body: { data: { deleted: true }, meta: {} } }, + ...overrides, + }) + } + + /** The list lands on its path with the stored query, and every list request carries it. */ + function expectRestored(router: { currentRoute: { value: { path: string; query: Record } } }, calls: Request[]) { + expect(router.currentRoute.value.path).toBe('/acme/demo/widgets') + expect(router.currentRoute.value.query).toEqual({ sort: 'code', dir: 'desc', 'filter[active]': 'true' }) + const lists = requestsTo(calls, 'GET', BASE) + expect(lists.length).toBeGreaterThanOrEqual(1) + for (const request of lists) { + const query = queryOf(request) + expect(query.get('sort')).toBe('code') + expect(query.get('dir')).toBe('desc') + expect(query.get('filter[active]')).toBe('true') + } + expect(readListState(LIST_ID)).toEqual(STORED) + } + + beforeEach(() => { + writeListState(LIST_ID, STORED) + }) + + it('tracer: the back action lands on the list with the stored query before the first list request', async () => { + const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip()) + expect(requestsTo(calls, 'GET', BASE)).toHaveLength(0) + await wrapper.find('[data-action="back"]').trigger('click') + await flushPromises() + expectRestored(router, calls) + }) + + it('cancel lands on the list with the stored query', async () => { + const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip()) + await wrapper.find('[data-action="cancel"]').trigger('click') + await flushPromises() + expectRestored(router, calls) + }) + + it('save and close follows a redirectClose to the list and lands with the stored query', async () => { + expect(formSchemaFixture.data.redirects.update.redirectClose).toBe('acme/demo/widgets') + const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip()) + await wrapper.find('[data-action="save-close"]').trigger('click') + await flushPromises() + expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1) + expectRestored(router, calls) + }) + + it('delete, once confirmed, lands on the list with the stored query', async () => { + const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip(), { attach: true }) + await wrapper.find('[data-action="delete"]').trigger('click') + await flushPromises() + ;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click() + await flushPromises() + expect(requestsTo(calls, 'DELETE', RECORD)).toHaveLength(1) + expectRestored(router, calls) + }) + + it('lands on the unfiltered list when nothing is stored', async () => { + writeListState(LIST_ID, {}) + const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip()) + await wrapper.find('[data-action="back"]').trigger('click') + await flushPromises() + expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets') + const lists = requestsTo(calls, 'GET', BASE) + expect(lists).toHaveLength(1) + expect([...queryOf(lists[0]!).keys()]).toEqual([]) + }) +}) diff --git a/admin/tests/list/ListView.test.ts b/admin/tests/list/ListView.test.ts index 9ed3400..160f24a 100644 --- a/admin/tests/list/ListView.test.ts +++ b/admin/tests/list/ListView.test.ts @@ -515,3 +515,126 @@ describe('list state in the URL and browser storage (D-01)', () => { expect(readListState('acme.demo.widgets')).toEqual({ search: 'blue' }) }) }) + +describe('list state entry logic (D-01)', () => { + const ID = 'acme.demo.widgets' + const KEY = `summer-admin.list:${ID}` + + function listQueries(calls: Request[]): URLSearchParams[] { + return requestsTo(calls, 'GET', BASE).map(queryOf) + } + + it('makes no list request without the stored params when the URL has none', async () => { + writeListState(ID, { search: 'blue', sort: 'code', dir: 'desc', per_page: 50, filter: { active: 'true' } }) + const { calls, router } = await mountApp('/acme/demo/widgets', routes()) + await flushPromises() + const sent = listQueries(calls) + expect(sent.length).toBeGreaterThanOrEqual(1) + for (const query of sent) { + expect(query.get('search')).toBe('blue') + expect(query.get('sort')).toBe('code') + expect(query.get('dir')).toBe('desc') + expect(query.get('per_page')).toBe('50') + expect(query.get('filter[active]')).toBe('true') + } + expect(router.currentRoute.value.query).toEqual({ + search: 'blue', + sort: 'code', + dir: 'desc', + per_page: '50', + 'filter[active]': 'true', + }) + }) + + it('lets every list param of the URL win and overwrites the whole stored query', async () => { + writeListState(ID, { search: 'old', sort: 'code', dir: 'desc', filter: { active: 'true' } }) + const { calls } = await mountApp('/acme/demo/widgets?sort=name&dir=asc&filter[active]=false', routes()) + await flushPromises() + const sent = listQueries(calls) + expect(sent).toHaveLength(1) + expect(sent[0]!.get('sort')).toBe('name') + expect(sent[0]!.get('search')).toBeNull() + expect(sent[0]!.get('filter[active]')).toBe('false') + expect(readListState(ID)).toEqual({ sort: 'name', dir: 'asc', filter: { active: 'false' } }) + }) + + it('forgets the stored query when every control is cleared, so the next entry loads unfiltered', async () => { + const first = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&filter[active]=true', routes()) + await flushPromises() + expect(window.localStorage.getItem(KEY)).not.toBeNull() + + await first.wrapper.find('[data-list-search]').setValue('') + await wait(350) + await flushPromises() + expect(first.router.currentRoute.value.query.search).toBeUndefined() + + await first.wrapper.find('[data-sort="code"]').trigger('click') + await flushPromises() + expect(first.router.currentRoute.value.query.sort).toBeUndefined() + expect(window.localStorage.getItem(KEY)).not.toBeNull() + + await first.wrapper.find('select[data-filter="active"]').setValue('') + await flushPromises() + expect(first.router.currentRoute.value.query).toEqual({}) + expect(window.localStorage.getItem(KEY)).toBeNull() + first.wrapper.unmount() + + const second = await mountApp('/acme/demo/widgets', routes()) + await flushPromises() + const sent = listQueries(second.calls) + expect(sent).toHaveLength(1) + expect([...sent[0]!.keys()]).toEqual([]) + expect(second.router.currentRoute.value.fullPath).toBe('/acme/demo/widgets') + }) + + it('keeps the filters when the same list is opened again from the navigation', async () => { + const { calls, router } = await mountApp('/acme/demo/widgets?sort=code&dir=desc&filter[active]=true', routes()) + await flushPromises() + const before = requestsTo(calls, 'GET', BASE).length + await router.push('/acme/demo/widgets') + await flushPromises() + expect(router.currentRoute.value.query).toEqual({ sort: 'code', dir: 'desc', 'filter[active]': 'true' }) + for (const query of listQueries(calls).slice(before)) { + expect(query.get('sort')).toBe('code') + expect(query.get('filter[active]')).toBe('true') + } + expect(listQueries(calls).every((query) => query.get('filter[active]') === 'true')).toBe(true) + expect(readListState(ID)).toEqual({ sort: 'code', dir: 'desc', filter: { active: 'true' } }) + }) + + it('drops the stored query when the list answers 422', async () => { + const invalid = { + status: 422, + body: { error: { code: 'validation_failed', message: 'Invalid', details: { sort: ['unknown column'] } } }, + } + writeListState(ID, { sort: 'gone', dir: 'asc' }) + const { calls } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: invalid })) + await flushPromises() + expect(queryOf(requestsTo(calls, 'GET', BASE)[0]!).get('sort')).toBe('gone') + expect(window.localStorage.getItem(KEY)).toBeNull() + expect(readListState(ID)).toEqual({}) + }) + + it.each([ + ['from the URL', '/acme/demo/widgets?page=5', false], + ['restored from storage', '/acme/demo/widgets', true], + ])('moves a page beyond last_page %s to last_page and requests that page', async (_label, url, stored) => { + if (stored) { + writeListState(ID, { page: 5 }) + } + const pageOf = (request: Request) => Number(queryOf(request).get('page') ?? '1') + const { calls, router } = await mountApp( + url, + routes({ + [`GET ${BASE}`]: (request: Request): Reply => { + const page = pageOf(request) + return { body: { data: page <= 2 ? listFixture.data : [], meta: { page, per_page: 20, total: 25, last_page: 2 } } } + }, + }), + ) + await flushPromises() + expect(requestsTo(calls, 'GET', BASE).map(pageOf)).toEqual([5, 2]) + expect(router.currentRoute.value.query).toEqual({ page: '2' }) + expect(readListState(ID)).toEqual({ page: 2 }) + }) +}) diff --git a/admin/tests/state/listState.test.ts b/admin/tests/state/listState.test.ts new file mode 100644 index 0000000..aba7726 --- /dev/null +++ b/admin/tests/state/listState.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { clearListStates, LIST_STATE_PREFIX, readListState, writeListState } from '../../src/state/listState' +import { SIDEBAR_KEY } from '../../src/state/useSidebar' + +// Browser-storage helpers of the per-list query memory (D-01). +const ID = 'acme.demo.widgets' +const KEY = `${LIST_STATE_PREFIX}${ID}` + +beforeEach(() => { + window.localStorage.clear() +}) + +afterEach(() => { + vi.restoreAllMocks() + window.localStorage.clear() +}) + +describe('list state keys', () => { + it('uses the summer-admin.list: prefix plus the controller id, one key per list', () => { + expect(LIST_STATE_PREFIX).toBe('summer-admin.list:') + writeListState(ID, { search: 'blue' }) + writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' }) + expect(Object.keys(window.localStorage).sort()).toEqual([ + 'summer-admin.list:acme.demo.gadgets', + 'summer-admin.list:acme.demo.widgets', + ]) + expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ search: 'blue' }) + expect(readListState('acme.demo.gadgets')).toEqual({ sort: 'name', dir: 'asc' }) + }) + + it('reads a missing key as the empty query', () => { + expect(readListState(ID)).toEqual({}) + expect(window.localStorage.getItem(KEY)).toBeNull() + }) +}) + +describe('readListState', () => { + it.each([ + ['invalid JSON', '{"search": '], + ['a JSON string', '"search=blue"'], + ['a JSON number', '42'], + ['JSON null', 'null'], + ['an array', '["search", "blue"]'], + ['an object with a number value', '{"search": "blue", "page": 2}'], + ['an object with a nested object', '{"filter": {"active": "true"}}'], + ['an object with a boolean value', '{"filter[active]": true}'], + ])('returns the empty query for %s and removes the key', (_label, raw) => { + window.localStorage.setItem(KEY, raw) + expect(readListState(ID)).toEqual({}) + expect(window.localStorage.getItem(KEY)).toBeNull() + }) + + it('drops malformed values through the strict parser like a hand-edited URL', () => { + window.localStorage.setItem( + KEY, + JSON.stringify({ + search: ' ', + dir: 'sideways', + page: '0', + per_page: 'many', + 'filter[active]': '', + 'filter[maker]': '3', + unknown: 'value', + 'filter[': 'x', + }), + ) + expect(readListState(ID)).toEqual({ filter: { maker: '3' } }) + }) + + it('reads an unknown direction next to a sort as ascending', () => { + window.localStorage.setItem(KEY, JSON.stringify({ sort: 'code', dir: 'sideways' })) + expect(readListState(ID)).toEqual({ sort: 'code', dir: 'asc' }) + }) + + it('keeps a well-formed stored object of strings in place', () => { + window.localStorage.setItem(KEY, JSON.stringify({ search: 'blue', page: '3' })) + expect(readListState(ID)).toEqual({ search: 'blue', page: 3 }) + expect(window.localStorage.getItem(KEY)).not.toBeNull() + }) +}) + +describe('writeListState', () => { + it('round-trips search, sort, dir, a page above 1, per_page and filters', () => { + const query = { + search: 'blue', + sort: 'code', + dir: 'desc' as const, + page: 3, + per_page: 50, + filter: { active: 'true', maker: '7' }, + } + writeListState(ID, query) + expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ + search: 'blue', + sort: 'code', + dir: 'desc', + page: '3', + per_page: '50', + 'filter[active]': 'true', + 'filter[maker]': '7', + }) + expect(readListState(ID)).toEqual(query) + }) + + it('stores the first page as no page', () => { + writeListState(ID, { sort: 'name', dir: 'asc', page: 1 }) + expect(readListState(ID)).toEqual({ sort: 'name', dir: 'asc' }) + }) + + it.each([ + ['the empty query', {}], + ['a query of empty filter values', { filter: { active: '' } }], + ['a first page only', { page: 1 }], + ])('removes the key for %s instead of storing it', (_label, query) => { + writeListState(ID, { search: 'blue' }) + expect(window.localStorage.getItem(KEY)).not.toBeNull() + writeListState(ID, query) + expect(window.localStorage.getItem(KEY)).toBeNull() + expect(Object.keys(window.localStorage)).toEqual([]) + }) +}) + +describe('clearListStates', () => { + it('removes every list key and keeps the sidebar preference and other keys', () => { + window.localStorage.setItem(SIDEBAR_KEY, 'true') + window.localStorage.setItem('unrelated', 'kept') + writeListState(ID, { search: 'blue' }) + writeListState('acme.demo.gadgets', { sort: 'name', dir: 'desc' }) + writeListState('acme.other.people', { filter: { active: 'true' } }) + clearListStates() + expect(Object.keys(window.localStorage).sort()).toEqual(['summer-admin.sidebar', 'unrelated']) + expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true') + }) + + it('is a no-op on empty storage', () => { + expect(() => clearListStates()).not.toThrow() + expect(Object.keys(window.localStorage)).toEqual([]) + }) +}) + +describe('storage that throws', () => { + it('reads the empty query and writes and clears without throwing', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('denied', 'SecurityError') + }) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('full', 'QuotaExceededError') + }) + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('denied', 'SecurityError') + }) + vi.spyOn(Storage.prototype, 'key').mockImplementation(() => { + throw new DOMException('denied', 'SecurityError') + }) + expect(readListState(ID)).toEqual({}) + expect(() => writeListState(ID, { search: 'blue' })).not.toThrow() + expect(() => writeListState(ID, {})).not.toThrow() + expect(() => clearListStates()).not.toThrow() + }) + + it('drops a malformed value even when the removal throws', () => { + window.localStorage.setItem(KEY, '{broken') + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('denied', 'SecurityError') + }) + expect(readListState(ID)).toEqual({}) + }) + + it('treats a window whose localStorage getter throws as no storage', () => { + vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { + throw new DOMException('denied', 'SecurityError') + }) + expect(readListState(ID)).toEqual({}) + expect(() => writeListState(ID, { search: 'blue' })).not.toThrow() + expect(() => writeListState(ID, {})).not.toThrow() + expect(() => clearListStates()).not.toThrow() + }) +}) diff --git a/admin/tests/state/useAuth.test.ts b/admin/tests/state/useAuth.test.ts index b71bd2e..b025b04 100644 --- a/admin/tests/state/useAuth.test.ts +++ b/admin/tests/state/useAuth.test.ts @@ -9,7 +9,9 @@ import { setCurrentUser, useAuth, } from '../../src/state/useAuth' +import { writeListState } from '../../src/state/listState' import { navigation, setNavigation } from '../../src/state/useNavigation' +import { SIDEBAR_KEY } from '../../src/state/useSidebar' import { setSettings, settings } from '../../src/state/useSettings' import { navigationFixture, settingsFixture } from '../fixtures/typed' import { API, mockApi, profile, requestsTo, resetState } from '../helpers' @@ -129,6 +131,24 @@ describe('logout (T-10-23)', () => { } }) + it.each([ + ['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }], + ['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }], + ['fails on the network', 'throw' as const], + ])('removes every stored list query and keeps the sidebar preference when the call %s', async (_label, route) => { + await signedIn(route) + window.localStorage.setItem(SIDEBAR_KEY, 'true') + writeListState('acme.demo.widgets', { search: 'blue', sort: 'code', dir: 'desc' }) + writeListState('acme.demo.gadgets', { filter: { active: 'true' } }) + expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toHaveLength(2) + + await logout({ replace: vi.fn().mockResolvedValue(undefined) }) + + expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toEqual([]) + expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY]) + expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true') + }) + it('works without a router and swallows a failed navigation', async () => { await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } }) await expect(logout()).resolves.toBeUndefined()