Files
summercms/admin/tests/form/DependentDropdown.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

236 lines
10 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')
})
})
// Plan 06 coverage: every UI-SPEC state, last request wins, stale stored
// values, the save recheck message and a dropdown inside a repeater row.
function deferred() {
let resolve: (reply: Reply) => void = () => undefined
const promise = new Promise<Reply>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('DependentDropdown states', () => {
it('shows loading while the options load, then the empty state naming the type', async () => {
const pending = deferred()
const { wrapper } = await mountApp(
'/acme/companions/links/1',
routes('category', null, { [`GET ${REFERENCES}`]: () => pending.promise }),
{ attach: true },
)
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
expect(wrapper.find('[data-field="reference"] [data-dependent]').attributes('data-dependent')).toBe('loading')
expect(select().attributes('disabled')).toBeDefined()
expect(select().attributes('aria-busy')).toBe('true')
expect(select().text()).toBe('Wczytywanie…')
pending.resolve({ body: { data: { applies: true, options: [] }, meta: {} } })
await flushPromises()
expect(select().attributes('aria-busy')).toBeUndefined()
expect(select().attributes('disabled')).toBeDefined()
const hint = wrapper.find('[data-dependent-hint]')
expect(hint.text()).toBe('Brak rekordów typu „Category”. Najpierw dodaj rekord albo wybierz inny typ.')
expect(select().attributes('aria-describedby')).toContain(hint.attributes('id'))
})
it('drops a late answer for an old sibling value: the last request wins', async () => {
const slow = deferred()
const { wrapper, calls } = await mountApp(
'/acme/companions/links/1',
routes('post', '7', {
[`GET ${REFERENCES}`]: (request) => (queryOf(request).get('values[type]') === 'category' ? slow.promise : references(request)),
}),
{ attach: true },
)
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
await wrapper.find('#field-type').setValue('2') // category: pending
await flushPromises()
expect(select().text()).toBe('Wczytywanie…')
await wrapper.find('#field-type').setValue('0') // url: answered at once
await flushPromises()
expect(select().text()).toBe('Nie dotyczy tego wyboru')
slow.resolve({ body: { data: { applies: true, options: [{ value: '3', label: 'News', depth: 0 }] }, meta: {} } })
await flushPromises()
expect(select().text()).toBe('Nie dotyczy tego wyboru')
// The dropped answer was not cached: choosing category again asks again.
await wrapper.find('#field-type').setValue('2')
await flushPromises()
expect(requestsTo(calls, 'GET', REFERENCES).filter((request) => queryOf(request).get('values[type]') === 'category')).toHaveLength(2)
})
it('shows the placeholder for a stored value no option offers, without clearing it', async () => {
let body: Record<string, unknown> | null = null
const { wrapper } = await mountApp(
'/acme/companions/links/1',
routes('post', '99', {
[`PUT ${RECORD}`]: async (request) => {
body = (await request.json()) as Record<string, unknown>
return {
status: 422,
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { reference: ['Wybierz jedną z dostępnych opcji.'] } } },
}
},
}),
{ attach: true },
)
const select = wrapper.find<HTMLSelectElement>('#field-reference')
expect(select.element.selectedOptions[0]!.text).toBe('Wybierz…')
expect(wrapper.find('[data-form-live]').text()).toBe('')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(body).toMatchObject({ type: 'post', reference: '99' })
expect(wrapper.find('[data-field="reference"]').text()).toContain('Wybierz jedną z dostępnych opcji.')
expect(wrapper.find('#field-reference').attributes('aria-invalid')).toBe('true')
})
it('a dropdown inside a repeater row asks with the row path and its row sibling', async () => {
const TARGETS = `${BASE}/fields/rows.target/dependent-options`
const { wrapper, calls } = await mountApp(
'/acme/companions/links/1',
routes('url', null, {
[`GET ${RECORD}`]: {
body: { data: { id: 1, title: 'Docs', type: 'url', reference: null, rows: [{ question: 'Q', kind: 'post', target: '8', visible: true }] }, meta: {} },
},
[`GET ${TARGETS}`]: (request) =>
queryOf(request).get('values[kind]') === 'post'
? { body: { data: { applies: true, options: [{ value: '7', label: 'Hello world', depth: 0 }, { value: '8', label: 'Nested post', depth: 1 }] }, meta: {} } }
: { status: 500, body: {} },
}),
{ attach: true },
)
const asked = requestsTo(calls, 'GET', TARGETS)
expect(asked).toHaveLength(1)
expect(queryOf(asked[0]!).get('values[kind]')).toBe('post')
const target = wrapper.find<HTMLSelectElement>('[data-repeater-row="0"] [data-field="target"] select')
expect(target.element.selectedOptions[0]!.text.trim()).toBe('Nested post')
})
})