Files
summercms/admin/tests/form/RepeaterField.test.ts
Jakub Zych 91593da1f7 test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
2026-10-12 19:29:37 +02:00

342 lines
15 KiB
TypeScript

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<string, Route> = {}): Record<string, Route> {
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<string, unknown> | null = null
const { wrapper, calls } = await mountApp(
'/acme/companions/links/1',
routes({
[`PUT ${RECORD}`]: async (request) => {
body = (await request.json()) as Record<string, unknown>
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<HTMLInputElement>('[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: '<p>text</p>', 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: '<p>one</p>' },
{ title: 'B', body: '<p>two</p>' },
])
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: '<p>one</p>' },
{ title: 'B', body: '<p>two</p>' },
])
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: '<p>one</p>' }], { 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)
})
})