Files
summercms/admin/tests/form/DependentDropdown.test.ts
Jakub Zych d3bf5d8f86 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
2026-10-12 16:08:23 +02:00

129 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
})
})