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 = { 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 = {}): Record { 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 | null = null const { wrapper, calls } = await mountApp( '/acme/companions/links/1', routes('post', '7', { [`PUT ${RECORD}`]: async (request) => { body = (await request.json()) as Record return { body: { data: { id: 1, ...body }, meta: {} } } }, }), { attach: true }, ) const select = () => wrapper.find('#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('#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('#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((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('#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('#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 | null = null const { wrapper } = await mountApp( '/acme/companions/links/1', routes('post', '99', { [`PUT ${RECORD}`]: async (request) => { body = (await request.json()) as Record 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('#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('[data-repeater-row="0"] [data-field="target"] select') expect(target.element.selectedOptions[0]!.text.trim()).toBe('Nested post') }) })