- 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
236 lines
10 KiB
TypeScript
236 lines
10 KiB
TypeScript
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')
|
||
})
|
||
})
|