feat(17-02): repeater field and dependent dropdown states
- 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
This commit is contained in:
142
admin/tests/fixtures/companions.form-schema.json
vendored
142
admin/tests/fixtures/companions.form-schema.json
vendored
@@ -3,25 +3,139 @@
|
||||
"name": "Link",
|
||||
"modelClass": "Link",
|
||||
"fields": [
|
||||
{ "name": "title", "type": "text", "label": "Title", "span": "left" },
|
||||
{ "name": "description", "type": "richeditor", "label": "Description", "comment": "Shown under the title." }
|
||||
{
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"label": "Title",
|
||||
"span": "left"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"type": "dropdown",
|
||||
"label": "Type",
|
||||
"span": "right",
|
||||
"options": [
|
||||
{
|
||||
"value": "url",
|
||||
"label": "Link"
|
||||
},
|
||||
{
|
||||
"value": "post",
|
||||
"label": "Post"
|
||||
},
|
||||
{
|
||||
"value": "category",
|
||||
"label": "Category"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "reference",
|
||||
"type": "dropdown",
|
||||
"label": "Reference",
|
||||
"span": "left",
|
||||
"dependsOn": "type"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"type": "richeditor",
|
||||
"label": "Description",
|
||||
"comment": "Shown under the title."
|
||||
},
|
||||
{
|
||||
"name": "rows",
|
||||
"type": "repeater",
|
||||
"label": "Questions",
|
||||
"span": "full",
|
||||
"prompt": "Add question",
|
||||
"titleFrom": "question",
|
||||
"maxItems": 3,
|
||||
"form": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "question",
|
||||
"type": "text",
|
||||
"label": "Question",
|
||||
"span": "left",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"type": "dropdown",
|
||||
"label": "Kind",
|
||||
"span": "right",
|
||||
"options": [
|
||||
{
|
||||
"value": "post",
|
||||
"label": "Post"
|
||||
},
|
||||
{
|
||||
"value": "url",
|
||||
"label": "Link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"type": "dropdown",
|
||||
"label": "Target",
|
||||
"span": "left",
|
||||
"dependsOn": "kind"
|
||||
},
|
||||
{
|
||||
"name": "visible",
|
||||
"type": "checkbox",
|
||||
"label": "Visible",
|
||||
"span": "right",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"create": { "other": "New link" },
|
||||
"update": { "other": "Edit link" },
|
||||
"saved": { "other": "Saved" },
|
||||
"deleteConfirm": { "other": "Delete this record?" },
|
||||
"deleted": { "other": "Record deleted" },
|
||||
"preview": { "other": "Link details" },
|
||||
"edit": { "other": "Edit link" }
|
||||
"create": {
|
||||
"other": "New link"
|
||||
},
|
||||
"update": {
|
||||
"other": "Edit link"
|
||||
},
|
||||
"saved": {
|
||||
"other": "Saved"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"other": "Delete this record?"
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Record deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Link details"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit link"
|
||||
}
|
||||
},
|
||||
"redirects": {
|
||||
"default": "acme/companions/links",
|
||||
"create": { "redirect": "acme/companions/links/:id", "redirectClose": "acme/companions/links" },
|
||||
"update": { "redirect": "acme/companions/links", "redirectClose": "acme/companions/links" }
|
||||
"create": {
|
||||
"redirect": "acme/companions/links/:id",
|
||||
"redirectClose": "acme/companions/links"
|
||||
},
|
||||
"update": {
|
||||
"redirect": "acme/companions/links",
|
||||
"redirectClose": "acme/companions/links"
|
||||
}
|
||||
},
|
||||
"meta": { "locale": "en" },
|
||||
"assets": { "scripts": [], "styles": [] }
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"assets": {
|
||||
"scripts": [],
|
||||
"styles": []
|
||||
}
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
|
||||
75
admin/tests/fixtures/lang.json
vendored
75
admin/tests/fixtures/lang.json
vendored
@@ -252,6 +252,27 @@
|
||||
"backend::lang.form.deleting": {
|
||||
"other": "Usuwanie…"
|
||||
},
|
||||
"backend::lang.form.dependent_cleared": {
|
||||
"other": "Wyczyszczono pole „:field”, bo zmieniły się jego opcje."
|
||||
},
|
||||
"backend::lang.form.dependent_empty": {
|
||||
"other": "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ."
|
||||
},
|
||||
"backend::lang.form.dependent_failed": {
|
||||
"other": "Nie udało się wczytać opcji."
|
||||
},
|
||||
"backend::lang.form.dependent_invalid": {
|
||||
"other": "Wybierz jedną z dostępnych opcji."
|
||||
},
|
||||
"backend::lang.form.dependent_not_used": {
|
||||
"other": "Nie dotyczy tego wyboru"
|
||||
},
|
||||
"backend::lang.form.dependent_retry": {
|
||||
"other": "Ponów wczytywanie opcji"
|
||||
},
|
||||
"backend::lang.form.dependent_waiting": {
|
||||
"other": "Najpierw wybierz pole „:field”"
|
||||
},
|
||||
"backend::lang.form.discard": {
|
||||
"other": "Porzuć zmiany"
|
||||
},
|
||||
@@ -609,6 +630,60 @@
|
||||
"backend::lang.relation.unlink": {
|
||||
"other": "Odłącz"
|
||||
},
|
||||
"backend::lang.repeater.add": {
|
||||
"other": "Dodaj pozycję"
|
||||
},
|
||||
"backend::lang.repeater.collapse": {
|
||||
"other": "Zwiń: :title"
|
||||
},
|
||||
"backend::lang.repeater.collapse_all": {
|
||||
"other": "Zwiń wszystkie"
|
||||
},
|
||||
"backend::lang.repeater.count": {
|
||||
"other": "Pozycje: :current z :max"
|
||||
},
|
||||
"backend::lang.repeater.empty": {
|
||||
"other": "Brak pozycji"
|
||||
},
|
||||
"backend::lang.repeater.empty_hint": {
|
||||
"other": "Użyj przycisku „:prompt” poniżej, aby dodać pierwszą."
|
||||
},
|
||||
"backend::lang.repeater.expand": {
|
||||
"other": "Rozwiń: :title"
|
||||
},
|
||||
"backend::lang.repeater.expand_all": {
|
||||
"other": "Rozwiń wszystkie"
|
||||
},
|
||||
"backend::lang.repeater.max_items": {
|
||||
"other": "Usuń pozycje, aby zostało ich najwyżej :max."
|
||||
},
|
||||
"backend::lang.repeater.min_items": {
|
||||
"other": "Dodaj pozycje, aby było ich co najmniej :min."
|
||||
},
|
||||
"backend::lang.repeater.move": {
|
||||
"other": "Przenieś: :title"
|
||||
},
|
||||
"backend::lang.repeater.move_down": {
|
||||
"other": "Przenieś niżej: :title"
|
||||
},
|
||||
"backend::lang.repeater.move_up": {
|
||||
"other": "Przenieś wyżej: :title"
|
||||
},
|
||||
"backend::lang.repeater.moved": {
|
||||
"other": "Przeniesiono :title na pozycję :position z :total."
|
||||
},
|
||||
"backend::lang.repeater.remove": {
|
||||
"other": "Usuń pozycję"
|
||||
},
|
||||
"backend::lang.repeater.remove_confirm": {
|
||||
"other": "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem."
|
||||
},
|
||||
"backend::lang.repeater.remove_named": {
|
||||
"other": "Usuń: :title"
|
||||
},
|
||||
"backend::lang.repeater.untitled": {
|
||||
"other": "Pozycja :n"
|
||||
},
|
||||
"backend::lang.richeditor.bold": {
|
||||
"other": "Pogrubienie"
|
||||
},
|
||||
|
||||
128
admin/tests/form/DependentDropdown.test.ts
Normal file
128
admin/tests/form/DependentDropdown.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the dependent dropdown (D-08). Plan 06
|
||||
// completes the coverage.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const RECORD = `${BASE}/1`
|
||||
const REFERENCES = `${BASE}/fields/reference/dependent-options`
|
||||
|
||||
const answers: Record<string, Reply> = {
|
||||
post: {
|
||||
body: {
|
||||
data: {
|
||||
applies: true,
|
||||
options: [
|
||||
{ value: '7', label: 'Hello world', depth: 0 },
|
||||
{ value: '8', label: 'Nested post', depth: 1 },
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
},
|
||||
category: { body: { data: { applies: true, options: [{ value: '3', label: 'News', depth: 0 }] }, meta: {} } },
|
||||
url: { body: { data: { applies: false, options: [] }, meta: {} } },
|
||||
}
|
||||
|
||||
function references(request: Request): Reply {
|
||||
return answers[queryOf(request).get('values[type]') ?? ''] ?? { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }
|
||||
}
|
||||
|
||||
function routes(type: string, reference: string | null, overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', type, reference, rows: [] }, meta: {} } },
|
||||
[`GET ${REFERENCES}`]: references,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('DependentDropdown', () => {
|
||||
it('DependentDropdown loads options for the sibling value and clears a stale value', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('post', '7', {
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: { data: { id: 1, ...body }, meta: {} } }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
// textContent: text() trims the U+00A0 depth indent.
|
||||
const optionTexts = () => select().findAll('option').map((option) => (option.element.textContent ?? '').replace(/^ +| +$/g, ''))
|
||||
|
||||
// Loaded for the stored sibling value, depth indented, stored value kept.
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(1)
|
||||
expect(queryOf(requestsTo(calls, 'GET', REFERENCES)[0]!).get('values[type]')).toBe('post')
|
||||
expect(select().attributes('disabled')).toBeUndefined()
|
||||
expect(optionTexts()).toEqual(['Hello world', ' Nested post'])
|
||||
expect(select().element.selectedOptions[0]!.text).toBe('Hello world')
|
||||
|
||||
// The sibling changes: new options, the stale value is cleared and announced.
|
||||
await wrapper.find('#field-type').setValue('2')
|
||||
await flushPromises()
|
||||
expect(queryOf(requestsTo(calls, 'GET', REFERENCES)[1]!).get('values[type]')).toBe('category')
|
||||
expect(optionTexts()).toEqual(['Wybierz…', 'News'])
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-form-live]').text()).toBe('Wyczyszczono pole „Reference”, bo zmieniły się jego opcje.')
|
||||
})
|
||||
|
||||
// A type without references: disabled, not used.
|
||||
await wrapper.find('#field-type').setValue('0')
|
||||
await flushPromises()
|
||||
expect(select().attributes('disabled')).toBeDefined()
|
||||
expect(optionTexts()).toEqual(['Nie dotyczy tego wyboru'])
|
||||
|
||||
// Back to a type seen before: answered from the cache, no new request.
|
||||
await wrapper.find('#field-type').setValue('1')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(3)
|
||||
expect(optionTexts()).toEqual(['Wybierz…', 'Hello world', ' Nested post'])
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(body).toMatchObject({ type: 'post', reference: null })
|
||||
})
|
||||
|
||||
it('waits for the sibling, and offers a retry when loading fails', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('', null, {
|
||||
[`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', type: null, reference: null, rows: [] }, meta: {} } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
expect(select.attributes('disabled')).toBeDefined()
|
||||
expect(select.text()).toBe('Najpierw wybierz pole „Type”')
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(0)
|
||||
|
||||
answers.post = { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }
|
||||
try {
|
||||
await wrapper.find('#field-type').setValue('1')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-field="reference"] [role="alert"]').text()).toBe('Nie udało się wczytać opcji.')
|
||||
} finally {
|
||||
answers.post = { body: { data: { applies: true, options: [{ value: '7', label: 'Hello world', depth: 0 }] }, meta: {} } }
|
||||
}
|
||||
await wrapper.find('[data-action="dependent-retry"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find<HTMLSelectElement>('#field-reference').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('#field-reference').text()).toContain('Hello world')
|
||||
})
|
||||
})
|
||||
166
admin/tests/form/RepeaterField.test.ts
Normal file
166
admin/tests/form/RepeaterField.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user