import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' import ListView from '../../src/views/ListView.vue' import { toasts } from '../../src/state/useToasts' import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets' import { clone, extensionListSchemaFixture, extensionPartialFixture, langFixture, listFixture, listSchemaFixture, rosterListFixture, rosterListSchemaFixture, } from '../fixtures/typed' import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' const BASE = `${API}/acme/demo/widgets` const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } } function routes(overrides: Record = {}): Record { return { [`GET ${BASE}/schema/list`]: { body: listSchemaFixture }, [`GET ${BASE}`]: { body: listFixture }, ...overrides, } } function schemaWith(change: (schema: typeof listSchemaFixture.data) => void) { const envelope = clone(listSchemaFixture) change(envelope.data) return { body: envelope } } async function selectAllAndDelete(wrapper: Awaited>['wrapper']) { await wrapper.find('th [role="checkbox"]').trigger('click') await wrapper.find('[data-action="delete"]').trigger('click') await flushPromises() ;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click() await flushPromises() } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('list view', () => { it('is the component of the list route', async () => { const { wrapper } = await mountApp('/acme/demo/widgets', routes()) expect(wrapper.findComponent(ListView).exists()).toBe(true) expect(wrapper.find('h1').text()).toBe('Widgets') expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets') }) it('sends the URL query to the list endpoint', async () => { const { calls } = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&page=2&filter[active]=true', routes()) const [request] = requestsTo(calls, 'GET', BASE) const query = queryOf(request!) expect(query.get('search')).toBe('blue') expect(query.get('sort')).toBe('code') expect(query.get('dir')).toBe('desc') expect(query.get('page')).toBe('2') expect(query.get('filter[active]')).toBe('true') }) it('announces a failed list load as an alert and keeps the toolbar', async () => { const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError })) expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.') expect(wrapper.find('table').exists()).toBe(false) expect(wrapper.find('[data-list-search]').exists()).toBe(true) }) it('announces a failed schema load and falls back to the navigation label', async () => { const { wrapper } = await mountApp('/acme/demo/gadgets', { [`GET ${API}/acme/demo/gadgets/schema/list`]: serverError, [`GET ${API}/acme/demo/gadgets`]: { body: listFixture }, }) expect(wrapper.find('h1').text()).toBe('Gadgets') expect(wrapper.find('[role="alert"]').exists()).toBe(true) expect(wrapper.find('[data-list-search]').exists()).toBe(false) }) it('shows the create button only when the toolbar declares it', async () => { const withCreate = await mountApp('/acme/demo/widgets', routes()) expect(withCreate.wrapper.find('[data-action="create"]').attributes('href')).toBe('/acme/demo/widgets/create') withCreate.wrapper.unmount() const { wrapper } = await mountApp( '/acme/demo/widgets', routes({ [`GET ${BASE}/schema/list`]: schemaWith((schema) => (schema.toolbarButtons = ['delete'])) }), ) expect(wrapper.find('[data-action="create"]').exists()).toBe(false) expect(wrapper.find('[data-action="delete"]').exists()).toBe(true) }) it('links no rows without a recordUrl and shows no checkboxes or sorting when turned off', async () => { const { wrapper } = await mountApp( '/acme/demo/widgets', routes({ [`GET ${BASE}/schema/list`]: schemaWith((schema) => { delete schema.recordUrl schema.showCheckboxes = false schema.showSorting = false }), }), ) expect(wrapper.find('tbody a').exists()).toBe(false) expect(wrapper.find('[role="checkbox"]').exists()).toBe(false) expect(wrapper.find('[data-sort]').exists()).toBe(false) }) it('toasts the server message when a bulk delete is refused', async () => { const { wrapper } = await mountApp( '/acme/demo/widgets', routes({ [`POST ${BASE}/bulk-delete`]: { status: 403, body: { error: { code: 'forbidden', message: 'Not allowed', details: {} } } } }), { attach: true }, ) await selectAllAndDelete(wrapper) expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' }) }) it('toasts the generic failure when the bulk delete cannot reach the server', async () => { const { wrapper } = await mountApp( '/acme/demo/widgets', routes({ [`POST ${BASE}/bulk-delete`]: () => { throw new TypeError('offline') }, }), { attach: true }, ) await selectAllAndDelete(wrapper) expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się usunąć zaznaczonych rekordów.', tone: 'danger' }) expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeUndefined() }) }) describe('list view extension points (Phase 10.1, D-03, D-11, D-12, D-14)', () => { const STATS = `${BASE}/partials/stats` const RECOUNT = `${BASE}/toolbar/recount` /** Plugin files never load in happy-dom: appended scripts and links are recorded instead. */ function stubHead(): { scripts: string[]; links: HTMLLinkElement[] } { const seen = { scripts: [] as string[], links: [] as HTMLLinkElement[] } const head = document.head const append = head.appendChild.bind(head) vi.spyOn(head, 'appendChild').mockImplementation((node: T): T => { if (node instanceof HTMLScriptElement) { seen.scripts.push(node.getAttribute('src') ?? '') queueMicrotask(() => node.dispatchEvent(new Event('load'))) return node } if (node instanceof HTMLLinkElement) { seen.links.push(node) return node } return append(node) }) return seen } function extensionRoutes(overrides: Record = {}): Record { return routes({ [`GET ${BASE}/schema/list`]: { body: extensionListSchemaFixture }, [`GET ${STATS}`]: { body: extensionPartialFixture }, ...overrides, }) } function deferred(): { route: () => Promise; release: (reply: Reply) => void } { let release: (reply: Reply) => void = () => undefined const pending = new Promise((resolve) => { release = resolve }) return { route: () => pending, release } } it('renders the header partial slot only when the schema names one', async () => { stubHead() const plain = await mountApp('/acme/demo/widgets', routes()) expect(plain.wrapper.find('[data-partial]').exists()).toBe(false) expect(plain.wrapper.find('[data-partial-loading]').exists()).toBe(false) expect(plain.calls.some((request) => new URL(request.url).pathname.includes('/partials/'))).toBe(false) plain.wrapper.unmount() const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes()) expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1) expect(queryOf(requestsTo(calls, 'GET', STATS)[0]!).has('id')).toBe(false) expect(wrapper.find('[data-partial="header"] dl.summer-stats').exists()).toBe(true) }) it('refetches the header after a bulk delete and a toolbar action only', async () => { stubHead() const { wrapper, calls, router } = await mountApp( '/acme/demo/widgets', extensionRoutes({ [`POST ${BASE}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } }, [`POST ${RECOUNT}`]: { body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } }, }), { attach: true }, ) const partials = () => requestsTo(calls, 'GET', STATS).length expect(partials()).toBe(1) for (const query of [{ search: 'blue' }, { sort: 'name', dir: 'desc' }, { 'filter[maker]': '1' }, { page: '2' }, { per_page: '50' }]) { await router.replace({ path: '/acme/demo/widgets', query }) await flushPromises() expect(partials(), JSON.stringify(query)).toBe(1) } await wrapper.find('[data-list-search]').setValue('red') await wait(350) await flushPromises() expect(partials()).toBe(1) await selectAllAndDelete(wrapper) expect(partials()).toBe(2) await wrapper.find('[data-action="recount"]').trigger('click') await flushPromises() expect(partials()).toBe(3) }) it('runs a toolbar action: POSTs {}, toasts and reloads the list', async () => { stubHead() const action = deferred() const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: action.route })) const lists = requestsTo(calls, 'GET', BASE).length await wrapper.find('[data-action="recount"]').trigger('click') await flushPromises() expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBe('true') await wrapper.find('[data-action="recount"]').trigger('click') const posts = requestsTo(calls, 'POST', RECOUNT) expect(posts).toHaveLength(1) expect(await posts[0]!.json()).toEqual({}) expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') action.release({ body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } }) await flushPromises() expect(toasts.value[0]).toMatchObject({ text: 'Counts refreshed', tone: 'success' }) expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1) expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBeUndefined() }) it('toasts nothing for an empty message but still reloads', async () => { stubHead() const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: { body: { data: { message: '', fill: {} }, meta: {} } } })) const lists = requestsTo(calls, 'GET', BASE).length await wrapper.find('[data-action="recount"]').trigger('click') await flushPromises() expect(toasts.value).toHaveLength(0) expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1) }) it.each([ ['the server message', { status: 422, body: { error: { code: 'validation_failed', message: 'Recount failed.', details: {} } } }, 'Recount failed.'], ['the framework text without one', { status: 500, body: {} }, 'Nie udało się wykonać akcji. Spróbuj ponownie.'], ])('toasts danger with %s and does not reload', async (_, reply, text) => { stubHead() const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: reply })) const lists = requestsTo(calls, 'GET', BASE).length const partials = requestsTo(calls, 'GET', STATS).length await wrapper.find('[data-action="recount"]').trigger('click') await flushPromises() expect(toasts.value[0]).toMatchObject({ text, tone: 'danger' }) expect(requestsTo(calls, 'GET', BASE).length).toBe(lists) expect(requestsTo(calls, 'GET', STATS).length).toBe(partials) expect(wrapper.find('[data-action="recount"]').attributes('disabled')).toBeUndefined() }) it('toasts the framework text when the action cannot reach the server', async () => { stubHead() const { wrapper } = await mountApp( '/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: () => { throw new TypeError('offline') }, }), ) await wrapper.find('[data-action="recount"]').trigger('click') await flushPromises() expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się wykonać akcji. Spróbuj ponownie.', tone: 'danger' }) }) it('loads the controller assets when the schema arrives, not before', async () => { const seen = stubHead() const schema = deferred() const envelope = clone(extensionListSchemaFixture) // Fresh URLs: the loader remembers files across the tests of this file. envelope.data.assets.scripts = ['/admin-test/assets/acme/demo/js/list.js?v=1'] envelope.data.assets.styles = ['/admin-test/assets/acme/demo/css/list.css?v=1'] await mountApp('/acme/demo/widgets', extensionRoutes({ [`GET ${BASE}/schema/list`]: schema.route })) expect(seen.links).toHaveLength(0) expect(seen.scripts).toHaveLength(0) schema.release({ body: envelope }) await flushPromises() expect(seen.scripts).toEqual(['/admin-test/assets/acme/demo/js/list.js?v=1']) expect(seen.links.map((link) => link.getAttribute('href'))).toEqual(['/admin-test/assets/acme/demo/css/list.css?v=1']) expect(seen.links[0]!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.widgets') expect(seen.links[0]!.disabled).toBe(false) }) }) describe('bulk actions on the list (UI-SPEC S1, D-09)', () => { const PEOPLE = `${API}/acme/roster/people` const strings = { 'backend::lang.list.bulk_actions': { other: 'Bulk actions' }, 'backend::lang.list.bulk_confirm': { other: 'Run “:action” on the selected (:count)?' }, 'backend::lang.list.bulk_done': { one: 'Action completed for :count record.', other: 'Action completed for :count records.' }, 'backend::lang.list.bulk_stale': { other: 'Some of the selected records are no longer available.' }, 'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' }, 'backend::lang.extension.action_failed': { other: 'The action could not be completed.' }, } type Mounted = Awaited>['wrapper'] function people(overrides: Record = {}): Record { return { [`GET ${PEOPLE}/schema/list`]: { body: rosterListSchemaFixture }, [`GET ${PEOPLE}`]: { body: rosterListFixture }, ...overrides, } } const ran = (affected: number, message = ''): Reply => ({ body: { data: { message, affected }, meta: {} } }) const refused = (status: number, message: string): Reply => ({ status, body: { error: { code: 'x', message, details: {} } } }) const trigger = (wrapper: Mounted) => wrapper.find('[data-action="bulk-actions"]') const dialog = () => document.body.querySelector('[data-confirm-dialog]') const listGets = (calls: Request[]) => requestsTo(calls, 'GET', PEOPLE).length async function select(wrapper: Mounted, count: number): Promise { for (const box of wrapper.findAll('td[data-select] [role="checkbox"]').slice(0, count)) { await box.trigger('click') } } async function choose(wrapper: Mounted, name: string): Promise { await trigger(wrapper).trigger('keydown', { key: 'Enter' }) await flushPromises() document.body.querySelector(`[data-bulk-menu] [data-bulk-action="${name}"]`)!.click() await flushPromises() } async function answer(action: 'confirm' | 'cancel'): Promise { dialog()!.querySelector(`[data-action="${action}"]`)!.click() await flushPromises() await wait(10) await flushPromises() } beforeEach(() => { setBundle({ ...langFixture.data, ...strings }, 'en') }) it('offers the declared actions without the built-in delete, and no menu when none is permitted', async () => { const { wrapper } = await mountApp('/acme/roster/people', people(), { attach: true }) await select(wrapper, 1) await trigger(wrapper).trigger('keydown', { key: 'Enter' }) await flushPromises() const names = Array.from(document.body.querySelectorAll('[data-bulk-menu] [data-bulk-action]')).map((item) => item.dataset.bulkAction) expect(names).toEqual(['activate', 'archive']) wrapper.unmount() document.body.innerHTML = '' const only = clone(rosterListSchemaFixture) only.data.bulkActions = [{ name: 'delete', label: 'Delete selected' }] const none = await mountApp('/acme/roster/people', people({ [`GET ${PEOPLE}/schema/list`]: { body: only } }), { attach: true }) await select(none.wrapper, 1) expect(trigger(none.wrapper).exists()).toBe(false) expect(none.wrapper.find('[data-action="delete"]').exists()).toBe(true) }) it('asks with the action text, or the default naming the action and the count', async () => { const { wrapper } = await mountApp('/acme/roster/people', people(), { attach: true }) await select(wrapper, 2) await choose(wrapper, 'activate') expect(dialog()!.textContent).toContain('Activate the selected people?') await answer('cancel') await choose(wrapper, 'archive') expect(dialog()!.textContent).toContain('Run “Archive” on the selected (2)?') expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe('Archive') }) it('posts the selected ids once, toasts the outcome, clears the selection and reloads', async () => { const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(2) }), { attach: true }) const before = listGets(calls) await select(wrapper, 2) await choose(wrapper, 'archive') await answer('confirm') const posts = requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`) expect(posts).toHaveLength(1) expect(await posts[0]!.json()).toEqual({ ids: [1, 2] }) expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Action completed for 2 records.', 'success']]) expect(wrapper.find('[data-selected-pill]').exists()).toBe(false) expect(listGets(calls)).toBe(before + 1) }) it('shows the server message when the action sends one', async () => { const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(1, 'One person was archived.') }), { attach: true }) await select(wrapper, 1) await choose(wrapper, 'archive') await answer('confirm') expect(toasts.value.map((toast) => toast.text)).toEqual(['One person was archived.']) }) it('keeps the dialog busy and the trigger disabled until the request settles', async () => { let release: (reply: Reply) => void = () => undefined const pending = new Promise((resolve) => { release = resolve }) const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: () => pending }), { attach: true }) await select(wrapper, 1) await choose(wrapper, 'archive') dialog()!.querySelector('[data-action="confirm"]')!.click() await flushPromises() expect(dialog()).not.toBeNull() expect(dialog()!.querySelector('[data-action="confirm"]')!.disabled).toBe(true) expect(trigger(wrapper).attributes('disabled')).toBeDefined() expect(toasts.value).toHaveLength(0) release(ran(1)) await flushPromises() await wait(10) await flushPromises() expect(dialog()).toBeNull() expect(toasts.value).toHaveLength(1) }) it.each([ [409, 'Conflict', 'Some of the selected records are no longer available.', false, 1], [403, 'Only team leads may archive people.', 'Only team leads may archive people.', true, 0], [403, '', 'You do not have permission to run this action.', true, 0], [500, '', 'The action could not be completed.', true, 0], ])('on %i (%j): toast %j, selection kept %s, reloads %i', async (status, message, text, kept, reloads) => { const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: refused(status, message) }), { attach: true }) const before = listGets(calls) await select(wrapper, 2) await choose(wrapper, 'archive') await answer('confirm') expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([[text, 'danger']]) expect(wrapper.find('[data-selected-pill]').exists()).toBe(kept) expect(listGets(calls)).toBe(before + reloads) }) it('backstop: focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel', async () => { const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: refused(403, 'Refused.') }), { attach: true }) await select(wrapper, 1) // By cancel: nothing is sent and the trigger has focus again. await choose(wrapper, 'archive') expect(dialog()).not.toBeNull() expect(document.activeElement).not.toBe(trigger(wrapper).element) await answer('cancel') expect(dialog()).toBeNull() expect(requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)).toHaveLength(0) expect(document.activeElement).toBe(trigger(wrapper).element) // By Escape, which is a cancel. await choose(wrapper, 'archive') dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })) await flushPromises() await wait(10) await flushPromises() if (dialog()) { await answer('cancel') } expect(document.activeElement).toBe(trigger(wrapper).element) // By confirm: the request ran and the trigger has focus again. ;(document.activeElement as HTMLElement).blur() await choose(wrapper, 'archive') await answer('confirm') expect(dialog()).toBeNull() expect(requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)).toHaveLength(1) expect(document.activeElement).toBe(trigger(wrapper).element) // The trigger is enabled, so the focus is on a control that can take it. expect(trigger(wrapper).attributes('disabled')).toBeUndefined() }) it('returns focus to the trigger before a successful run clears the selection', async () => { const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(1) }), { attach: true }) await select(wrapper, 1) const focused: boolean[] = [] trigger(wrapper).element.addEventListener('focus', () => focused.push(trigger(wrapper).attributes('disabled') === undefined)) await choose(wrapper, 'archive') await answer('confirm') // Focus reached the trigger while it was still enabled. expect(focused).toContain(true) expect(wrapper.find('[data-selected-pill]').exists()).toBe(false) }) })