- RepeaterField: rows with drag handle, index, titleFrom title, error chip, move up/down (buttons, ArrowUp/ArrowDown on the handle, native drag and drop), expand/collapse, remove (asks only for a row holding a value), add with nested defaults, maxItems counter, empty state, collapse all; every change edits form state only - 422 row paths (content.3.answer) open the row, mark it and focus the nested control; the banner no longer repeats row messages - DropdownField dependsOn: waiting, loading, not used, empty, loaded with depth indent, failed with retry; cached per sibling value, last request wins, a stale value is cleared after a sibling change and announced - FormView provides form feedback (errors and notices by row path, the polite live region, its confirm dialog) and the options cache; FormGrid provides the sibling scope for repeater rows - repeater.* and form.dependent_* phrase keys (en, pl), smoke specs, docs
167 lines
7.1 KiB
TypeScript
167 lines
7.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
|
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')
|
|
})
|
|
})
|