import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' import { setSettings } from '../../src/state/useSettings' import { toasts } from '../../src/state/useToasts' import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed' import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' // Phase 17 plan 02 smoke specs for the tree reorder page (D-09), the list's // Reorder button and the singleton form (D-11). Plan 06 completes them. const BASE = `${API}/acme/companions/links` const REORDER = `${BASE}/reorder` function tree(): Reply { return { body: { data: { title: 'Reorder links', partition: { label: 'Group', value: 5, options: [ { value: 5, label: 'Main' }, { value: 6, label: 'Footer' }, ], }, nodes: [ { id: 1, name: 'Home', parent_id: null, sort_order: 1 }, { id: 2, name: 'Blog', parent_id: null, sort_order: 2 }, { id: 3, name: 'News', parent_id: 2, sort_order: 3 }, { id: 4, name: '', parent_id: null, sort_order: 4 }, ], }, meta: {}, }, } } /** The tree as "name(children)" strings, in display order. */ function shape(wrapper: VueWrapper): string { const read = (list: Element): string => Array.from(list.children) .map((item) => { const name = item.querySelector(':scope > [data-reorder-node] > span.min-w-0')?.textContent?.trim() ?? '?' const children = item.querySelector(':scope > ul') return children ? `${name}(${read(children)})` : name }) .join(' ') return read(wrapper.find('[data-reorder-tree]').element) } const handle = (wrapper: VueWrapper, id: number) => wrapper.find(`[data-reorder-handle="${id}"]`) beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('ReorderView', () => { it('ReorderView tracer: a keyboard move sends one debounced POST with the full node set', async () => { let body: unknown = null const { wrapper, calls } = await mountApp( '/acme/companions/links/reorder', { [`GET ${REORDER}`]: tree(), [`POST ${REORDER}`]: async (request) => { body = await request.json() return tree() }, }, { attach: true }, ) expect(wrapper.find('h1').text()).toBe('Reorder links') expect(wrapper.find('[data-reorder-tree]').attributes('aria-label')).toBe('Kolejność rekordów') expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)') expect(wrapper.find('[data-reorder-partition]').element.value).toBe('5') // First row: up, indent and outdent are impossible. const first = wrapper.find('[data-reorder-node="1"]') expect(first.find('[data-action="reorder-up"]').attributes('disabled')).toBeDefined() expect(first.find('[data-action="reorder-indent"]').attributes('disabled')).toBeDefined() expect(first.find('[data-action="reorder-outdent"]').attributes('disabled')).toBeDefined() // Three quick moves by keyboard on the handles. keydown(handle(wrapper, 1).element, 'ArrowDown') await flushPromises() expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)') expect(document.activeElement).toBe(handle(wrapper, 1).element) keydown(handle(wrapper, 1).element, 'ArrowRight') await flushPromises() expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)') keydown(handle(wrapper, 4).element, 'ArrowUp') await flushPromises() expect(shape(wrapper)).toBe('Bez tytułu (#4) Blog(News Home)') expect(document.activeElement).toBe(handle(wrapper, 4).element) expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(0) await vi.waitFor(() => { expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1) }) expect(body).toEqual({ partition: 5, nodes: [ { id: 4, parent_id: null, sort_order: 1 }, { id: 2, parent_id: null, sort_order: 2 }, { id: 3, parent_id: 2, sort_order: 3 }, { id: 1, parent_id: 2, sort_order: 4 }, ], }) await vi.waitFor(() => { expect(wrapper.find('[data-reorder-status]').text()).toBe('Zapisano kolejność') }) expect(wrapper.find('[data-reorder-live]').text()).toBe('Przeniesiono Bez tytułu (#4) na pozycję 1 z 2.') await wait(500) expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1) }) it('restores the saved tree when a save fails and reloads on a stale set', async () => { let status = 500 const { wrapper, calls } = await mountApp( '/acme/companions/links/reorder', { [`GET ${REORDER}`]: tree(), [`POST ${REORDER}`]: () => ({ status, body: { error: { code: status === 409 ? 'reorder_stale' : 'x', message: 'x', details: {} } } }), }, { attach: true }, ) await wrapper.find('[data-reorder-node="1"] [data-action="reorder-down"]').trigger('click') expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)') await vi.waitFor(() => { expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać nowej kolejności. Przywrócono poprzednią.', tone: 'danger' }) }) expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)') status = 409 await wrapper.find('[data-reorder-node="2"] [data-action="reorder-outdent"]').trigger('click') await wrapper.find('[data-reorder-node="3"] [data-action="reorder-outdent"]').trigger('click') await vi.waitFor(() => { expect(toasts.value[0]).toMatchObject({ text: 'Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie.', tone: 'danger' }) }) await flushPromises() expect(requestsTo(calls, 'GET', REORDER)).toHaveLength(2) expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)') }) it('shows the empty state and the 403 alert', async () => { const empty = tree() ;(empty.body as { data: { nodes: unknown[] } }).data.nodes = [] const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: empty }) expect(wrapper.find('[data-reorder-empty]').text()).toContain('Nie ma jeszcze czego porządkować') wrapper.unmount() const forbidden = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } }, }) expect(forbidden.wrapper.find('[role="alert"]').text()).toContain('Nie masz uprawnień') }) }) describe('list Reorder button', () => { it('links to the reorder page with the filtered partition', async () => { const schema = clone(listSchemaFixture) schema.data.toolbarButtons = ['create', 'reorder'] schema.data.reorder = { title: 'Reorder links', partition: { column: 'group_id', label: 'Group' } } schema.data.filters = [...schema.data.filters, { ...schema.data.filters[0]!, name: 'group', label: 'Group', column: 'group_id' }] const routes: Record = { [`GET ${BASE}/schema/list`]: { body: schema }, [`GET ${BASE}`]: { body: listFixture }, } const { wrapper } = await mountApp('/acme/companions/links?filter[group]=6', routes) const buttons = wrapper.findAll('header [data-action]').map((button) => button.attributes('data-action')) expect(buttons).toEqual(['reorder', 'create']) const reorder = wrapper.find('[data-action="reorder"]') expect(reorder.text()).toBe('Zmień kolejność rekordów') expect(reorder.attributes('href')).toBe('/acme/companions/links/reorder?partition=6') }) }) describe('singleton form', () => { it('opens the one record from settings, without Delete, and returns to the settings index', async () => { setSettings([ { code: 'profile', label: 'Site profile', description: '', icon: 'settings', category: 'CMS', keywords: [], model: '', controller: 'acme.companions.profiles', order: 1, }, ]) const schema = clone(companionsFormSchemaFixture) schema.data.singleton = true schema.data.fields = schema.data.fields.filter((field) => field.name === 'title') const PROFILES = `${API}/acme/companions/profiles` const { wrapper, router, calls } = await mountApp( '/settings', { [`GET ${PROFILES}/singleton`]: { body: { data: { id: 9 }, meta: {} } }, [`GET ${PROFILES}/schema/form`]: { body: schema }, [`GET ${PROFILES}/9`]: { body: { data: { id: 9, title: 'Main site' }, meta: {} } }, }, { attach: true }, ) await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0 }) await flushPromises() expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1) expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9') expect(wrapper.find('[data-action="delete"]').exists()).toBe(false) expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień') await wrapper.find('[data-action="cancel"]').trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/settings') }) })