import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import RelationField from '../../src/components/form/fields/RelationField.vue' import { formField, optionsFixture, recordFixture } from '../fixtures/typed' import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers' const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' } const MAKER = `${API}/acme/demo/widgets/fields/maker/options` const TAGS = `${API}/acme/demo/widgets/fields/tags/options` function routes(overrides: Record = {}): Record { return { [`GET ${MAKER}`]: (request) => { const query = queryOf(request) if (query.get('search') === 'acme') { return { body: optionsFixture.makerFieldSearch } } return { body: query.get('page') === '2' ? optionsFixture.makerFieldPage2 : optionsFixture.makerField } }, [`GET ${TAGS}`]: { body: optionsFixture.tagsField }, ...overrides, } } function mountField(name: string, modelValue: unknown, extra: Record = {}) { return mount(RelationField, { props: { field: formField(name), modelValue, controlId: `r-${name}`, labels: recordFixture.meta.labels[name], source, ...extra, }, attachTo: document.body, }) } const combobox = (wrapper: VueWrapper) => wrapper.find('[role="combobox"]') const optionTexts = (wrapper: VueWrapper) => wrapper.findAll('[role="option"]').map((option) => option.text()) async function openList(wrapper: VueWrapper) { await combobox(wrapper).trigger('click') await flushPromises() } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('read-only relation (D-26)', () => { it('shows only the label from meta.labels, with no control', () => { const wrapper = mountField('owner', 5, { describedBy: 'r-owner-comment' }) const box = wrapper.find('[data-relation-readonly]') expect(box.text()).toBe('dana@example.test') expect(box.attributes('id')).toBe('r-owner') expect(box.attributes('aria-describedby')).toBe('r-owner-comment') expect(box.classes()).toContain('text-text') expect(wrapper.find('input').exists()).toBe(false) }) it('falls back to the emptyOption, then a muted dash', () => { const withEmpty = mountField('owner', null, { labels: [], field: { ...formField('owner'), emptyOption: 'Nobody' } }) expect(withEmpty.find('[data-relation-readonly]').text()).toBe('Nobody') const bare = mountField('owner', null, { labels: undefined, field: { ...formField('owner'), emptyOption: undefined } }) expect(bare.find('[data-relation-readonly]').text()).toBe('—') expect(bare.find('[data-relation-readonly]').classes()).toContain('text-muted') }) }) describe('single relation (D-17)', () => { it('is a collapsed combobox showing the current label', () => { mockApi(routes()) const wrapper = mountField('maker', 2) const input = combobox(wrapper) expect(input.attributes('aria-expanded')).toBe('false') expect(input.attributes('aria-controls')).toBe('r-maker-listbox') expect(input.attributes('aria-autocomplete')).toBe('list') expect((input.element as HTMLInputElement).value).toBe('Bolt & Co') expect(wrapper.find('[role="listbox"]').attributes('id')).toBe('r-maker-listbox') }) it('loads the first page of 20 options on open, emptyOption first and muted', async () => { const calls = mockApi(routes()) const wrapper = mountField('maker', 2) await openList(wrapper) const [request] = requestsTo(calls, 'GET', MAKER) expect(queryOf(request!).get('page')).toBe('1') expect(queryOf(request!).get('per_page')).toBe('20') expect(queryOf(request!).has('search')).toBe(false) expect(combobox(wrapper).attributes('aria-expanded')).toBe('true') expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co']) const options = wrapper.findAll('[role="option"]') expect(options[0]!.attributes('data-value')).toBe('empty') expect(options[0]!.classes()).toContain('text-muted') expect(options.map((option) => option.attributes('aria-selected'))).toEqual(['false', 'false', 'true']) }) it('chooses an id or null and closes', async () => { mockApi(routes()) const wrapper = mountField('maker', 2) await openList(wrapper) await wrapper.findAll('[role="option"]')[1]!.trigger('mousedown') expect(wrapper.emitted('update:modelValue')).toEqual([[1]]) expect(combobox(wrapper).attributes('aria-expanded')).toBe('false') await openList(wrapper) await wrapper.findAll('[role="option"]')[0]!.trigger('mousedown') expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([null]) }) it('moves the active option with the arrows, exposes it as aria-activedescendant and picks it with Enter', async () => { mockApi(routes()) const wrapper = mountField('maker', null) const input = combobox(wrapper).element expect(keydown(input, 'ArrowDown').defaultPrevented).toBe(true) await flushPromises() expect(combobox(wrapper).attributes('aria-activedescendant')).toBeUndefined() keydown(input, 'ArrowDown') keydown(input, 'ArrowDown') keydown(input, 'ArrowDown') keydown(input, 'ArrowDown') await flushPromises() expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-2') keydown(input, 'ArrowUp') await flushPromises() expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-1') expect(keydown(input, 'Enter').defaultPrevented).toBe(true) expect(wrapper.emitted('update:modelValue')).toEqual([[1]]) }) it('closes on Escape and blur, and ignores Enter and Escape while closed', async () => { mockApi(routes()) const wrapper = mountField('maker', 2) const input = combobox(wrapper).element expect(keydown(input, 'Enter').defaultPrevented).toBe(false) expect(keydown(input, 'Escape').defaultPrevented).toBe(false) await openList(wrapper) expect(keydown(input, 'Escape').defaultPrevented).toBe(true) await flushPromises() expect(combobox(wrapper).attributes('aria-expanded')).toBe('false') await openList(wrapper) await combobox(wrapper).trigger('blur') expect(combobox(wrapper).attributes('aria-expanded')).toBe('false') expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) it('searches after a 300 ms debounce and loads more on demand', async () => { const calls = mockApi(routes()) const wrapper = mountField('maker', null) await openList(wrapper) expect(wrapper.find('[data-action="more-options"]').exists()).toBe(true) await wrapper.find('[data-action="more-options"]').trigger('mousedown') await flushPromises() expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co', 'Cog Ltd']) expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('page')).toBe('2') expect(wrapper.find('[data-action="more-options"]').exists()).toBe(false) await combobox(wrapper).setValue('acme') await wait(150) expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2) await wait(200) await flushPromises() expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('search')).toBe('acme') expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works']) }) it('loads more when the list is scrolled to its end', async () => { const calls = mockApi(routes()) const wrapper = mountField('maker', null) await openList(wrapper) const list = wrapper.find('[role="listbox"]').element as HTMLElement Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 400 }) Object.defineProperty(list, 'clientHeight', { configurable: true, value: 256 }) list.scrollTop = 10 list.dispatchEvent(new Event('scroll')) await flushPromises() expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(1) list.scrollTop = 140 list.dispatchEvent(new Event('scroll')) await flushPromises() expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2) }) it('shows "no options" for an empty or failed options load', async () => { mockApi(routes({ [`GET ${MAKER}`]: { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } } })) const wrapper = mountField('maker', null) await openList(wrapper) expect(optionTexts(wrapper)).toEqual(['— none —']) expect(wrapper.find('[role="listbox"]').text()).toContain('Brak wyników') }) it('never requests options without a source controller', async () => { const calls = mockApi(routes()) const wrapper = mountField('maker', null, { source: null }) await openList(wrapper) expect(calls).toHaveLength(0) }) }) describe('multiple relation (D-18)', () => { it('shows ordered chips with initials and labelled remove buttons', async () => { mockApi(routes()) const wrapper = mountField('tags', [3, 1]) const chips = wrapper.findAll('[data-chip]') expect(chips.map((chip) => chip.find('.font-semibold').text())).toEqual(['Round', 'Small']) expect(chips[0]!.find('[aria-hidden="true"]').text()).toBe('R') expect(chips.map((chip) => chip.find('button').attributes('aria-label'))).toEqual(['Usuń: Round', 'Usuń: Small']) await chips[0]!.find('button').trigger('click') expect(wrapper.emitted('update:modelValue')).toEqual([[[1]]]) }) it('offers only unselected options and appends the chosen id', async () => { mockApi(routes()) const wrapper = mountField('tags', [3, 1]) await openList(wrapper) expect(optionTexts(wrapper)).toEqual(['Large']) expect(wrapper.find('[role="option"]').attributes('aria-selected')).toBe('false') await wrapper.find('[role="option"]').trigger('mousedown') expect(wrapper.emitted('update:modelValue')).toEqual([[[3, 1, 4]]]) }) it('removes the last chip on Backspace with an empty query', async () => { mockApi(routes()) const wrapper = mountField('tags', [3, '1', 'x']) keydown(combobox(wrapper).element, 'Backspace') expect(wrapper.emitted('update:modelValue')).toEqual([[[3]]]) const empty = mountField('tags', []) keydown(combobox(empty).element, 'Backspace') expect(empty.emitted('update:modelValue')).toBeUndefined() }) it('labels an unknown id with the id itself', () => { mockApi(routes()) const wrapper = mountField('tags', [99], { labels: [] }) expect(wrapper.find('[data-chip] .font-semibold').text()).toBe('99') }) })