import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { ref } from 'vue' import type { AdminRecord, FormField } from '../../src/api/types' // The registry first: it imports RepeaterField, whose FormGrid imports it back. import '../../src/components/form/registry' import RepeaterField from '../../src/components/form/fields/RepeaterField.vue' import { FORM_FEEDBACK, type FormFeedback } from '../../src/components/form/formContext' import { companionsFormSchemaFixture } from '../fixtures/typed' import { API, keydown, mountApp, requestsTo, resetState, type Route } from '../helpers' // Phase 17 plan 02 smoke specs for the repeater field (D-07). Plan 06 // completes the coverage. const BASE = `${API}/acme/companions/links` const RECORD = `${BASE}/1` const TARGETS = `${BASE}/fields/rows.target/dependent-options` function record() { return { data: { id: 1, title: 'Docs', description: '', rows: [ { question: 'First', kind: 'url', target: null, visible: true }, { question: 'Second', kind: 'url', target: null, visible: false }, ], }, meta: {}, } } function routes(overrides: Record = {}): Record { return { [`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture }, [`GET ${RECORD}`]: { body: record() }, [`GET ${TARGETS}`]: { body: { data: { applies: false, options: [] }, meta: {} } }, ...overrides, } } const titles = (wrapper: VueWrapper) => wrapper.findAll('[data-repeater="rows"] [data-repeater-title]').map((node) => node.text()) const rowAt = (wrapper: VueWrapper, index: number) => wrapper.find(`[data-repeater="rows"] [data-repeater-row="${index}"]`) function confirmDialog(): HTMLElement | null { return document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('RepeaterField', () => { it('RepeaterField tracer: add, edit nested, reorder and remove change form state only', async () => { let body: Record | null = null const { wrapper, calls } = await mountApp( '/acme/companions/links/1', routes({ [`PUT ${RECORD}`]: async (request) => { body = (await request.json()) as Record return { body: { data: { id: 1, ...body }, meta: {} } } }, }), { attach: true }, ) const group = wrapper.find('[data-repeater="rows"]') expect(group.attributes('role')).toBe('group') expect(group.attributes('aria-labelledby')).toBe('field-rows-label') expect(wrapper.find('#field-rows-label').text()).toBe('Questions') expect(titles(wrapper)).toEqual(['First', 'Second']) expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 2 z 3') // Add: a third row with nested defaults; the counter reaches maxItems. await wrapper.find('[data-action="repeater-add"]').trigger('click') await flushPromises() expect(titles(wrapper)).toEqual(['First', 'Second', 'Pozycja 3']) expect(wrapper.find('[data-action="repeater-add"]').attributes('disabled')).toBeDefined() expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 3 z 3') // Edit a nested field: the row title follows titleFrom. const third = rowAt(wrapper, 2) const question = third.find('[data-field="question"] input') expect(question.attributes('id')).toMatch(/^field-rows-r\d+-question$/) await question.setValue('Third') expect(titles(wrapper)).toEqual(['First', 'Second', 'Third']) // Reorder by button and by keyboard on the handle; the move is announced. await rowAt(wrapper, 0).find('[data-action="repeater-down"]').trigger('click') await flushPromises() expect(titles(wrapper)).toEqual(['Second', 'First', 'Third']) await vi.waitFor(() => { expect(wrapper.find('[data-form-live]').text()).toBe('Przeniesiono First na pozycję 2 z 3.') }) const handle = rowAt(wrapper, 2).find('[data-repeater-handle]') keydown(handle.element, 'ArrowUp') await flushPromises() expect(titles(wrapper)).toEqual(['Second', 'Third', 'First']) expect(document.activeElement).toBe(rowAt(wrapper, 1).find('[data-repeater-handle]').element) // Remove a row that holds a value: it asks first. await rowAt(wrapper, 0).find('[data-action="repeater-remove"]').trigger('click') await flushPromises() expect(confirmDialog()).not.toBeNull() expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Usunąć „Second”?') confirmDialog()!.click() await flushPromises() expect(titles(wrapper)).toEqual(['Third', 'First']) // An added row holding only its defaults is removed at once. await wrapper.find('[data-action="repeater-add"]').trigger('click') await flushPromises() await rowAt(wrapper, 2).find('[data-action="repeater-remove"]').trigger('click') await flushPromises() expect(confirmDialog()).toBeNull() expect(titles(wrapper)).toEqual(['Third', 'First']) // Nothing was sent before Save; Save sends the rows as edited. expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0) expect(calls.every((request) => request.method === 'GET')).toBe(true) await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1) expect(body!.rows).toEqual([ { question: 'Third', kind: null, target: null, visible: true }, { question: 'First', kind: 'url', target: null, visible: true }, ]) }) it('expands rows with path errors, shows the chip and focuses the first invalid nested control', async () => { const { wrapper } = await mountApp( '/acme/companions/links/1', routes({ [`PUT ${RECORD}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { 'rows.1.question': ['Question is required.'] } } }, }, }), { attach: true }, ) // Collapse the second row first: the error opens it again. await rowAt(wrapper, 1).find('[data-action="repeater-toggle"]').trigger('click') await flushPromises() expect(rowAt(wrapper, 1).find('[data-field="question"]').exists()).toBe(false) await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const row = rowAt(wrapper, 1) expect(row.classes()).toContain('border-danger') expect(row.find('[data-repeater-errors]').text()).toBe('1 błąd') expect(row.find('[data-field="question"]').text()).toContain('Question is required.') expect(document.activeElement).toBe(row.find('[data-field="question"] input').element) // The banner counts it but does not repeat the row message. expect(wrapper.find('[data-error-banner]').text()).not.toContain('Question is required.') }) it('shows the empty state with the prompt', async () => { const { wrapper } = await mountApp( '/acme/companions/links/1', routes({ [`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', rows: [] }, meta: {} } } }), ) expect(wrapper.find('[data-repeater-empty]').text()).toContain('Brak pozycji') expect(wrapper.find('[data-repeater-empty]').text()).toContain('Użyj przycisku „Add question” poniżej, aby dodać pierwszą.') expect(wrapper.find('[data-action="repeater-add"]').text()).toBe('Add question') }) }) // Plan 06 coverage on the field alone, with a recording form feedback: // row bounds, remove and reorder semantics, collapsed rows, read-only. const field: FormField = { name: 'rows', label: 'Rows', type: 'repeater', span: 'full', titleFrom: 'title', minItems: 1, maxItems: 3, form: { fields: [ { name: 'title', type: 'text', label: 'Title', span: 'left' }, { name: 'body', type: 'richeditor', label: 'Body', span: 'full' }, { name: 'on', type: 'checkbox', label: 'On', span: 'right', default: true }, ], }, } as FormField function repeater(rows: AdminRecord[], options: { confirm?: boolean; readOnly?: boolean } = {}) { const announced: string[] = [] const asked: string[] = [] const feedback: FormFeedback = { errors: ref({}), notices: ref({}), clearError: () => undefined, announce: (text) => announced.push(text), confirm: async (request) => { asked.push(request.message) return options.confirm ?? true }, } const wrapper: VueWrapper = mount(RepeaterField, { props: { field: options.readOnly ? { ...field, readOnly: true } : field, modelValue: rows, controlId: 'field-rows', 'onUpdate:modelValue': (value: unknown) => wrapper.setProps({ modelValue: value }), }, global: { provide: { [FORM_FEEDBACK as symbol]: feedback } }, attachTo: document.body, }) const values = () => (wrapper.props() as { modelValue: AdminRecord[] }).modelValue return { wrapper, announced, asked, values } } const rowsOf = (wrapper: VueWrapper) => wrapper.findAll('[data-repeater-title]').map((node) => node.text()) const row = (wrapper: VueWrapper, index: number) => wrapper.find(`[data-repeater-row="${index}"]`) describe('RepeaterField rows', () => { it('enforces minItems and maxItems on remove, add and the counter', async () => { const { wrapper, values } = repeater([{ title: 'Only', body: null, on: true }]) expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeDefined() expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 1 z 3') expect(wrapper.find('[data-action="repeater-toggle-all"]').exists()).toBe(false) await wrapper.find('[data-action="repeater-add"]').trigger('click') await wrapper.find('[data-action="repeater-add"]').trigger('click') await flushPromises() expect(values()).toEqual([ { title: 'Only', body: null, on: true }, { title: null, body: null, on: true }, { title: null, body: null, on: true }, ]) expect(rowsOf(wrapper)).toEqual(['Only', 'Pozycja 2', 'Pozycja 3']) expect(wrapper.find('[data-action="repeater-add"]').attributes('disabled')).toBeDefined() expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 3 z 3') // A fourth add does nothing. await wrapper.find('[data-action="repeater-add"]').trigger('click') expect(values()).toHaveLength(3) expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeUndefined() }) it('asks before removing a row with a value, never for an empty or default row, and keeps it on cancel', async () => { const { wrapper, asked, values } = repeater( [ { title: 'Kept', body: null, on: true }, { title: '', body: '', on: true }, { title: null, body: '

text

', on: true }, ], { confirm: false }, ) // Only defaults and empty values: removed at once. await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click') await flushPromises() expect(asked).toEqual([]) expect(values()).toHaveLength(2) // A rich text value: asked, and cancel keeps the row. await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click') await flushPromises() expect(asked).toEqual(['Usunąć „Pozycja 2”? Zmiana zostanie zapisana razem z rekordem.']) expect(values()).toHaveLength(2) expect(rowsOf(wrapper)).toEqual(['Kept', 'Pozycja 2']) // Removing moves focus to the handle that takes the row's place. const confirmed = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }], { confirm: true }) await row(confirmed.wrapper, 1).find('[data-action="repeater-remove"]').trigger('click') await flushPromises() expect(confirmed.asked).toHaveLength(1) expect(rowsOf(confirmed.wrapper)).toEqual(['A', 'C']) expect(document.activeElement).toBe(row(confirmed.wrapper, 1).find('[data-repeater-handle]').element) }) it('reorders by the arrow keys and the buttons, announces repeater.moved and stops at the ends', async () => { const { wrapper, announced, values } = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }]) expect(row(wrapper, 0).find('[data-action="repeater-up"]').attributes('disabled')).toBeDefined() expect(row(wrapper, 2).find('[data-action="repeater-down"]').attributes('disabled')).toBeDefined() keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowUp') await flushPromises() expect(wrapper.emitted('update:modelValue')).toBeUndefined() const down = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowDown') await flushPromises() expect(down.defaultPrevented).toBe(true) expect(rowsOf(wrapper)).toEqual(['B', 'A', 'C']) expect(document.activeElement).toBe(row(wrapper, 1).find('[data-repeater-handle]').element) expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.']) await row(wrapper, 2).find('[data-action="repeater-up"]').trigger('click') await flushPromises() expect(rowsOf(wrapper)).toEqual(['B', 'C', 'A']) await row(wrapper, 0).find('[data-action="repeater-down"]').trigger('click') await flushPromises() expect(rowsOf(wrapper)).toEqual(['C', 'B', 'A']) expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.', 'Przeniesiono C na pozycję 2 z 3.', 'Przeniesiono B na pozycję 2 z 3.']) expect(values().map((item) => item.title)).toEqual(['C', 'B', 'A']) // Other keys do nothing. const other = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowRight') expect(other.defaultPrevented).toBe(false) }) it('collapsed rows do not mount their richeditors and keep their values', async () => { const { wrapper, values } = repeater([ { title: 'A', body: '

one

' }, { title: 'B', body: '

two

' }, ]) const editors = () => wrapper.findAll('[data-field="body"]').length expect(editors()).toBe(2) await row(wrapper, 0).find('[data-action="repeater-toggle"]').trigger('click') expect(row(wrapper, 0).find('[data-repeater-body]').exists()).toBe(false) expect(row(wrapper, 0).find('[data-action="repeater-toggle"]').attributes('aria-expanded')).toBe('false') expect(editors()).toBe(1) const all = wrapper.find('[data-action="repeater-toggle-all"]') expect(all.text()).toBe('Zwiń wszystkie') await all.trigger('click') expect(editors()).toBe(0) expect(wrapper.find('[data-action="repeater-toggle-all"]').text()).toBe('Rozwiń wszystkie') expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(values()).toEqual([ { title: 'A', body: '

one

' }, { title: 'B', body: '

two

' }, ]) await wrapper.find('[data-action="repeater-toggle-all"]').trigger('click') expect(editors()).toBe(2) expect(row(wrapper, 1).find('[data-action="repeater-toggle"]').attributes('aria-controls')).toBe(row(wrapper, 1).find('[data-repeater-body]').attributes('id')) }) it('read-only shows every row expanded with no controls', () => { const { wrapper } = repeater([{ title: 'A', body: '

one

' }], { readOnly: true }) expect(wrapper.find('[data-repeater-handle]').exists()).toBe(false) expect(wrapper.find('[data-action="repeater-add"]').exists()).toBe(false) expect(wrapper.find('[data-action="repeater-remove"]').exists()).toBe(false) expect(wrapper.find('[data-repeater-body]').exists()).toBe(true) }) })