feat: relation field and picker

This commit is contained in:
Florian Sylvain committed 2026-10-23 00:13:42 +02:00
1 parent b45c99be3b
commit 37a77c9c22
92 files changed
+6826 -80

No files matched your search

@@ -0,0 +1,243 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { RelationSummary } from '@/lib/relationField'
import {
calls,
eventEntries,
installSelectPolyfills,
listPage,
mountList,
text,
unmountAll,
} from './contentTestKit'
import { drawsIcon } from './mediaFieldKit'
import { eventsFields } from './schemaTestKit'
import { apiField } from '@/lib/__tests__/schemaFixtures'
import { relationField, summaryFor } from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(async () => {
void router
await unmountAll()
})
const KEY = 'renewcms-content-columns-events'
const COLUMNS = ['title', 'starts_on', 'venue', 'speakers']
const MISSING: RelationSummary = { id: 9, missing: true }
type Relations = Record<string, RelationSummary | RelationSummary[]>
const RELATIONS: Record<number, [number | null, number[], Relations]> = {
42: [3, [11, 12, 13, 14], {}],
41: [null, [12], {}],
40: [4, [], {}],
}
const eventType = () => ({
id: 1,
key: 'events',
name: 'Events',
icon: 'files',
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-10-15T09:30:00Z',
fields: [
eventsFields[0]!,
eventsFields[1]!,
relationField('Venue', 'venue', 'venues', 'one_to_many'),
relationField('Speakers', 'speakers', 'speakers', 'many_to_many'),
],
})
function itemOf(id: number, overrides: { relations?: Relations; venue?: number | null } = {}) {
const [venue, speakers] = RELATIONS[id]!
const base = eventEntries.find((entry) => entry.id === id)!
const shownVenue = overrides.venue === undefined ? venue : overrides.venue
return {
...base,
data: { ...base.data, venue: shownVenue, speakers },
relations: overrides.relations ?? {
...(shownVenue === null ? {} : { venue: summaryFor('venues', shownVenue) }),
...(speakers.length === 0
? {}
: { speakers: speakers.map((speaker) => summaryFor('speakers', speaker)) }),
},
}
}
function mountRelations(
width?: number,
items = [itemOf(42), itemOf(41), itemOf(40)],
type = eventType,
columns: string[] = COLUMNS,
) {
return mountList('/content/events', {
width,
before: () => localStorage.setItem(KEY, JSON.stringify(columns)),
type,
content: () => [type()],
list: (_key, query) => listPage(items, items.length, query),
})
}
const headers = () => [...document.body.querySelectorAll('thead th')]
const rowsOf = () => [...document.body.querySelectorAll('tbody tr')]
const cellOf = (row: number, column: number) => rowsOf()[row]!.querySelectorAll('td')[column]!
const chipsIn = (element: Element) => [...element.querySelectorAll('[data-testid="relation-chip"]')]
describe('The relation cell', () => {
it('shows one chip for a single relation', async () => {
await mountRelations()
const chips = chipsIn(cellOf(0, 3))
expect(chips.map(text)).toEqual(['Rue Verte hall'])
expect(chips[0]!.className).toContain('h-5')
expect(chips[0]!.className).toContain('px-1.5')
expect(chips[0]!.className).toContain('bg-(--rc-sand)')
})
it('shows two chips and a muted +2 for four speakers', async () => {
await mountRelations()
expect(chipsIn(cellOf(0, 4)).map(text)).toEqual(['Ines Duarte', 'Jonas Pereira'])
expect(text(cellOf(0, 4))).toBe('Ines Duarte Jonas Pereira +2')
const more = [...cellOf(0, 4).querySelectorAll('span')].find((span) => text(span) === '+2')!
expect(more.className).toContain('text-muted')
})
it('shows the muted dash for no venue and for an empty speakers list', async () => {
await mountRelations()
expect(text(cellOf(1, 3))).toBe('–')
expect(cellOf(1, 3).querySelector('span')!.className).toContain('text-muted')
expect(text(cellOf(2, 4))).toBe('–')
expect(text(cellOf(1, 4))).toBe('Jonas Pereira')
expect(text(cellOf(2, 3))).toBe('Rue Verte yard')
})
it('shows a dashed chip for a missing venue', async () => {
await mountRelations(undefined, [
itemOf(42, { venue: 9, relations: { venue: MISSING } }),
itemOf(41),
itemOf(40),
])
const chip = chipsIn(cellOf(0, 3))[0]!
expect(text(chip)).toBe('Entry no longer exists')
expect(chip.getAttribute('data-missing')).toBe('true')
expect(chip.className).toContain('border-dashed')
})
it('reads each item own relations with no extra request', async () => {
await mountRelations()
expect(calls.filter((url) => url.includes('/venues') || url.includes('/speakers'))).toEqual(
[],
)
})
})
describe('The grid of the board', () => {
it('lists the relation headers with the wide column and no sort', async () => {
await mountRelations()
expect(headers().map(text).slice(0, 5)).toEqual([
'ID',
'Title',
'Starts on',
'Venue',
'Speakers',
])
for (const index of [3, 4]) {
expect(headers()[index]!.className).toContain('w-50')
expect(headers()[index]!.querySelector('svg')).toBeNull()
expect(headers()[index]!.hasAttribute('aria-sort')).toBe(false)
}
})
it('keeps rows 48 px high', async () => {
await mountRelations()
expect(cellOf(0, 0).className).toContain('h-12')
})
})
describe('The mobile card', () => {
it('lists the title, then a labelled line for Venue and Speakers', async () => {
await mountRelations(MOBILE_WIDTH)
const cards = [...document.body.querySelectorAll('[data-testid="entry-card"]')]
const lines = [...cards[0]!.querySelectorAll('[data-testid="entry-card-relation"]')]
expect(lines.map(text)).toEqual([
'Venue Rue Verte hall',
'Speakers Ines Duarte Jonas Pereira +2',
])
expect(text(cards[1]!.querySelectorAll('[data-testid="entry-card-relation"]')[0])).toBe(
'Venue –',
)
expect(text(cards[2]!.querySelectorAll('[data-testid="entry-card-relation"]')[1])).toBe(
'Speakers –',
)
})
})
describe('The columns menu', () => {
it('lists Venue and Speakers with the link icon and their keys', async () => {
await mountRelations()
const toggle = [...document.body.querySelectorAll('button')].find(
(button) =>
text(button) === 'Columns' || button.getAttribute('aria-label') === 'Columns',
)!
toggle.click()
await vi.waitFor(() =>
expect(document.body.querySelectorAll('[data-testid="column-row"]').length).toBe(4),
)
const rows = [...document.body.querySelectorAll('[data-testid="column-row"]')]
expect(rows.map(text).slice(2)).toEqual(['Venue venue', 'Speakers speakers'])
expect(drawsIcon(rows[2]!, 'link')).toBe(true)
expect(drawsIcon(rows[3]!, 'link')).toBe(true)
})
})
describe('A relation as the first column', () => {
it('keeps the link to the editor around the chips', async () => {
const type = () => {
const [, , venue, speakers] = eventType().fields
return { ...eventType(), fields: [venue!, speakers!, eventsFields[0]!] }
}
await mountRelations(undefined, undefined, type, ['venue', 'speakers', 'title'])
const link = rowsOf()[0]!.querySelector<HTMLAnchorElement>('[data-testid="entry-link"]')!
expect(chipsIn(link).map(text)).toEqual(['Rue Verte hall'])
expect(link.getAttribute('href')).toBe('/content/events/42')
})
it('reads the labels joined by a comma as plain text on the card', async () => {
const type = () => {
const [, , venue, speakers] = eventType().fields
return { ...eventType(), fields: [speakers!, venue!, eventsFields[0]!] }
}
await mountRelations(MOBILE_WIDTH, undefined, type, ['speakers', 'venue', 'title'])
const titles = [
...document.body.querySelectorAll('[data-testid="entry-card"] .truncate'),
].map(text)
expect(titles[0]).toBe('Ines Duarte, Jonas Pereira, Ana Costa, Luc Moreau')
expect(titles.join('|')).not.toMatch(/\b1[1-4]\b/)
})
})
describe('Fixtures', () => {
it('keeps unrelated field helpers', () => {
expect(apiField('X', 'x', 'text').relation).toBeNull()
})
})
@@ -0,0 +1,563 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
calls,
chip,
entryWrites,
fieldWrapper,
installSelectPolyfills,
save,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
allTypes,
chipLabels,
entryWithRelations,
liveText,
mountRelation,
openList,
optionLabels,
pickOption,
press,
relationError,
relationInput,
removeButtons,
settleSearch,
summaryFor,
typeSearch,
} from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
installSelectPolyfills()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
})
afterEach(unmountAll)
const searches = () => calls.filter((url) => url.includes('/entry?') && !url.endsWith('size=5'))
const sentData = (index = 0) => entryWrites[index]!.body.data as Record<string, unknown>
const fieldKeys = () =>
[...document.body.querySelectorAll<HTMLElement>('[data-entry-field]')].map(
(element) => element.dataset.entryField,
)
describe('Where the controls sit', () => {
it('follows the schema order, full width, each with its label', async () => {
await mountRelation('/content/events/42', { venue: 3, speakers: [11, 12] })
expect(fieldKeys().slice(-2)).toEqual(['venue', 'speakers'])
for (const key of ['venue', 'speakers']) {
const row = fieldWrapper(key).parentElement!
expect(row.children).toHaveLength(1)
expect(row.className).not.toContain('md:grid-cols-2')
}
expect(text(fieldWrapper('venue').querySelector('label'))).toBe('Venue')
expect(text(fieldWrapper('speakers').querySelector('label'))).toBe('Speakers')
})
it('never pairs with a boolean, number or date field before it', async () => {
await mountRelation('/content/events/new')
const rows = [...document.body.querySelectorAll('#entry-form > div')]
const venueRow = rows.find((row) => row.querySelector('[data-entry-field="venue"]'))!
expect(venueRow.children).toHaveLength(1)
expect(
venueRow.previousElementSibling!.querySelector('[data-entry-field="description"]'),
).not.toBeNull()
})
})
describe('Single relation, empty, focused search', () => {
it('sends only the trimmed, debounced search and lists rows with a second line', async () => {
await mountRelation('/content/events/42')
await typeSearch('venue', ' ru ')
await vi.advanceTimersByTimeAsync(100)
await typeSearch('venue', 'rue ')
await settleSearch(vi)
expect(searches()).toEqual(['/v1/content-type/venues/entry?q=rue&page=1&size=10'])
expect(optionLabels()).toEqual([
'Rue Verte hall Lyon · 120 seats',
'Rue Verte yard Lyon · outdoor',
'Rue du Port studio Nantes · 24 seats',
])
expect(relationInput('venue').getAttribute('placeholder')).toBe('Search to add')
expect(
document.body.querySelector('[role="option"]')!.hasAttribute('data-highlighted'),
).toBe(true)
expect(liveText('venue')).toBe('3 results')
})
it('lists the first page without q when the input is empty', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi)
expect(searches()).toEqual(['/v1/content-type/venues/entry?page=1&size=10'])
expect(optionLabels()).toHaveLength(4)
})
it('never lets a stale answer overwrite a newer one', async () => {
const gates = new Map<string, (value: unknown) => void>()
await mountRelation('/content/events/42', {
search: (_key, query) =>
new Promise((resolve) => gates.set(query.get('q') ?? '', resolve)),
})
const body = (name: string) => ({
items: [{ id: 3, data: { name, city: '', details: '' }, relations: {} }],
total: 1,
page: 1,
size: 10,
})
await typeSearch('venue', 'old')
await settleSearch(vi)
await typeSearch('venue', 'new')
await settleSearch(vi)
gates.get('new')!(body('Fresh'))
await flushPromises()
gates.get('old')!(body('Stale'))
await flushPromises()
expect(optionLabels()).toEqual(['Fresh'])
})
})
describe('Picking a single target', () => {
it('shows one chip, sets the value, dirties the page and announces it', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(chipLabels('venue')).toEqual(['Rue Verte hall'])
expect(liveText('venue')).toBe('Venue set to Rue Verte hall.')
expect(chip()!.textContent).toBe('Unsaved changes')
expect(relationInput('venue').getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(relationInput('venue'))
expect(calls.filter((url) => url.includes('/venues/entry/'))).toEqual([])
await save()
expect(sentData().venue).toBe(3)
})
it('removes the chip with its x and says the field was cleared', async () => {
await mountRelation('/content/events/42', { venue: 3 })
expect(chipLabels('venue')).toEqual(['Rue Verte hall'])
removeButtons('venue')[0]!.click()
await flushPromises()
expect(chipLabels('venue')).toEqual([])
expect(liveText('venue')).toBe('Venue cleared.')
await save()
expect(sentData().venue).toBeNull()
})
it('replaces the chip when another row is picked', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
expect(optionLabels()).not.toContain('Rue Verte hall Lyon · 120 seats')
await pickOption('Rue Verte yard')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
await save()
expect(sentData().venue).toBe(4)
})
})
describe('Many relation', () => {
it('shows the chips, the placeholder and the help line', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira'])
expect(relationInput('speakers').getAttribute('placeholder')).toBe('Search to add')
expect(text(fieldWrapper('speakers').querySelector('[data-testid="relation-help"]'))).toBe(
'An event can have many speakers.',
)
})
it('adds at the end, hides picked rows and announces', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
await openList('speakers', vi, 'ana')
expect(optionLabels()).toEqual(['Ana Costa'])
await pickOption('Ana Costa')
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira', 'Ana Costa'])
expect(liveText('speakers')).toBe('Ana Costa added to Speakers.')
await save()
expect(sentData().speakers).toEqual([11, 12, 13])
})
it('removes a chip with its x or Backspace in the empty input', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
removeButtons('speakers')[0]!.click()
await flushPromises()
expect(chipLabels('speakers')).toEqual(['Jonas Pereira'])
expect(liveText('speakers')).toBe('Ines Duarte removed from Speakers.')
relationInput('speakers').focus()
await press('speakers', 'Backspace')
expect(chipLabels('speakers')).toEqual([])
expect(liveText('speakers')).toBe('Jonas Pereira removed from Speakers.')
})
it('names each x "Remove <name> from <field>"', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
expect(
removeButtons('speakers').map((remove) => remove.getAttribute('aria-label')),
).toEqual(['Remove Ines Duarte from Speakers', 'Remove Jonas Pereira from Speakers'])
expect(removeButtons('speakers')[0]!.hasAttribute('aria-labelledby')).toBe(false)
})
it('cannot add a 51st target', async () => {
const ids = Array.from({ length: 50 }, (_, index) => 100 + index)
const relations = {
speakers: ids.map((id) => ({
id,
content_type: 'speakers',
first_field: { key: 'name', type: 'text', value: `Speaker ${id}` },
})),
}
await mountRelation('/content/events/42', { speakers: ids, relations })
await openList('speakers', vi)
expect(optionLabels()).toEqual([])
expect(text(document.body.querySelector('[data-testid="relation-empty"]'))).toBe(
'You can add up to 50.',
)
})
})
describe('What the control shows and sends', () => {
it('sends null and [] for a create left empty, never absent', async () => {
const wrapper = await mountRelation('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(entryWrites).toHaveLength(1)
expect(entryWrites[0]!.method).toBe('POST')
expect(entryWrites[0]!.url).toBe('/v1/content-type/events/entry')
expect(sentData()).toMatchObject({ venue: null, speakers: [] })
expect('venue' in sentData()).toBe(true)
})
it('sends a JSON number once a venue is picked on create', async () => {
const wrapper = await mountRelation('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
await save()
expect(sentData().venue).toBe(3)
})
it.each([
['loaded 3 and saved untouched', 3, [], 'Rue Verte hall', 3],
['loaded null and saved untouched', null, [], undefined, null],
])('%s', async (_name, venue, speakers, shown, sent) => {
await mountRelation('/content/events/42', { venue, speakers })
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(chipLabels('venue')[0]).toBe(shown)
expect(sentData().venue).toBe(sent)
})
it('saves a missing venue untouched with the same id', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(sentData().venue).toBe(3)
})
it('adds Jonas Pereira to [11] and removes both from [11, 12]', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
await openList('speakers', vi, 'jonas')
await pickOption('Jonas Pereira')
expect(chipLabels('speakers')).toHaveLength(2)
await save()
expect(sentData().speakers).toEqual([11, 12])
})
it('removes both speakers and sends an empty array', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
removeButtons('speakers')[0]!.click()
await flushPromises()
removeButtons('speakers')[0]!.click()
await flushPromises()
await save()
expect(sentData().speakers).toEqual([])
})
it('rebuilds the control from the answered relations after a save', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
await save()
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
expect(chip()!.textContent).toContain('Saved')
})
})
describe('Dirty tracking', () => {
it('is saved again after picking 4 then 3', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
expect(chip()!.textContent).toBe('Unsaved changes')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(chip()!.textContent).toBe('Saved')
expect(button('Save')?.hasAttribute('disabled')).toBe(true)
})
it('counts the same ids in another order as changed', async () => {
const wrapper = await mountRelation('/content/events/42', { speakers: [11, 12] })
await setField(wrapper, 'speakers', [12, 11])
expect(chip()!.textContent).toBe('Unsaved changes')
await setField(wrapper, 'speakers', [11, 12])
expect(chip()!.textContent).toBe('Saved')
})
})
describe('Required and empty', () => {
it('sends nothing, shows the error, the chip and focuses the control', async () => {
await mountRelation('/content/events/42', { venueField: { required: true } })
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites).toHaveLength(0)
expect(relationError('venue')).toBe('Choose a Venue.')
expect(chip()!.textContent).toBe('Not saved')
expect(document.activeElement).toBe(relationInput('venue'))
expect(relationInput('venue').getAttribute('aria-invalid')).toBe('true')
expect(relationInput('venue').getAttribute('aria-required')).toBe('true')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(relationError('venue')).toBe('')
expect(chip()!.textContent).toBe('Unsaved changes')
})
it('focuses only the first field in schema order that has an error', async () => {
await mountRelation('/content/events/42', {
venueField: { required: true },
speakersField: { required: true },
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(document.activeElement).toBe(relationInput('venue'))
expect(relationError('speakers')).toBe('Choose a Speakers.')
})
})
describe('Server answers for a relation field', () => {
it.each([
[422, 'invalid_entry: venue: required', 'Choose a Venue.'],
[422, 'invalid_entry: venue: type', 'Venue must point to an existing entry.'],
[409, 'invalid_entry: venue: unique', 'That Venue is already used by another entry.'],
])('%i %s shows its copy under the control', async (status, body, message) => {
const { ApiError } = await import('@/composables/useApi')
await mountRelation('/content/events/42', {
venue: 3,
writeEntry: () => {
throw new ApiError(status, undefined, body)
},
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(relationError('venue')).toBe(message)
expect(chip()!.textContent).toBe('Not saved')
expect(document.activeElement).toBe(relationInput('venue'))
expect(document.body.querySelector('[role="alert"]')?.textContent).not.toContain(
'Not saved',
)
})
it('shows the read-only reload alert for unknown_field, as for media', async () => {
const { ApiError } = await import('@/composables/useApi')
await mountRelation('/content/events/42', {
venue: 3,
writeEntry: () => {
throw new ApiError(422, undefined, 'invalid_entry: venue: unknown_field')
},
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(text(document.body)).toContain('The fields of Events changed')
expect(text(document.body)).toContain(
'The Venue field was changed after you opened this form.',
)
expect(button('Reload form')).toBeDefined()
})
})
describe('Saving', () => {
it('dims the form and makes the control inoperable while the PUT is pending', async () => {
let release!: (value: unknown) => void
await mountRelation('/content/events/42', {
venue: 3,
speakers: [11],
writeEntry: () => new Promise((resolve) => (release = resolve)),
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(document.body.querySelector('#entry-form')!.className).toContain('opacity-70')
expect(relationInput('venue').disabled).toBe(true)
expect(button('Saving…')).toBeDefined()
release(entryWithRelations(3, [11]))
await flushPromises()
})
})
describe('Accessibility of the control', () => {
it('points aria-controls at its own listbox, not a foreign one earlier in the page', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
const foreign = document.createElement('div')
foreign.setAttribute('role', 'listbox')
foreign.id = 'foreign-listbox'
document.body.prepend(foreign)
await openList('speakers', vi, 'a')
const own = document.body.querySelector(
'[data-relation-owner] [role="listbox"], [data-relation-owner][role="listbox"]',
)!
expect(own.id).not.toBe('')
expect(relationInput('speakers').getAttribute('aria-controls')).toBe(own.id)
foreign.remove()
})
it('is a labelled combobox on a listbox of options', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
await openList('speakers', vi, 'a')
const input = relationInput('speakers')
const listbox = document.body.querySelector('[role="listbox"]')!
expect(input.getAttribute('aria-expanded')).toBe('true')
expect(input.getAttribute('aria-controls')).toBe(listbox.id)
expect(input.getAttribute('aria-activedescendant')).toBe(
document.body.querySelector('[role="option"]')!.id,
)
expect(fieldWrapper('speakers').querySelector('label')!.getAttribute('for')).toBe(input.id)
const group = fieldWrapper('speakers').querySelector('[role="group"]')!
expect(text(document.getElementById(group.getAttribute('aria-labelledby')!))).toBe(
'Speakers',
)
expect(input.getAttribute('aria-describedby')).toContain('-help')
})
it('has one polite live region', async () => {
await mountRelation('/content/events/42', { venue: 3 })
const live = fieldWrapper('venue').querySelector('[data-testid="relation-live"]')!
expect(live.getAttribute('role')).toBe('status')
expect(live.getAttribute('aria-live')).toBe('polite')
})
})
describe('Keyboard', () => {
it('moves with the arrows, picks with Enter and closes with Escape keeping the text', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi, 'rue')
await press('venue', 'ArrowDown')
const highlighted = () =>
text(document.body.querySelector('[role="option"][data-highlighted]'))
expect(highlighted()).toContain('Rue Verte yard')
await press('venue', 'ArrowUp')
expect(highlighted()).toContain('Rue Verte hall')
await press('venue', 'ArrowDown')
await press('venue', 'Enter')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
await openList('venue', vi, 'port')
await press('venue', 'Escape')
expect(relationInput('venue').getAttribute('aria-expanded')).toBe('false')
expect(relationInput('venue').value).toBe('port')
})
})
describe('Chips and the keyboard', () => {
it('reaches the chips with ArrowLeft from the empty input', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
relationInput('speakers').focus()
await press('speakers', 'ArrowLeft')
const items = [...fieldWrapper('speakers').querySelectorAll('[data-slot="tagsItem"]')]
expect(items.map((item) => item.getAttribute('data-state'))).toEqual(['inactive', 'active'])
})
it('keeps Backspace for the text when the input is not empty', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
await typeSearch('speakers', 'ab')
await press('speakers', 'Backspace')
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira'])
})
})
describe('Fixtures', () => {
it('keeps the sample data of the board', () => {
expect(allTypes()).toHaveLength(3)
expect(summaryFor('venues', 3).id).toBe(3)
})
})
describe('A relation as the first field', () => {
it('titles the page and the breadcrumb "Entry 42"', async () => {
const types = allTypes()
const [events] = types
const relationFirst = { ...events!, fields: [events!.fields[7]!, events!.fields[0]!] }
await mountRelation('/content/events/42', {
venue: 3,
type: () => relationFirst,
content: () => [relationFirst, ...types.slice(1)],
})
expect(text(document.body.querySelector('h1'))).toBe('Entry 42')
expect(text(document.body)).toContain('Events · Entry 42')
})
})
@@ -0,0 +1,230 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import {
button,
calls,
entryWrites,
fieldWrapper,
installSelectPolyfills,
save,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
chipLabels,
emptyText,
liveText,
mountRelation,
openList,
optionLabels,
pickOption,
relationError,
relationInput,
searchBody,
settleSearch,
typeSearch,
} from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
installSelectPolyfills()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
})
afterEach(unmountAll)
const SKELETON_DELAY_MS = 150
const searches = () => calls.filter((url) => url.includes('/venues/entry?'))
const empty = { items: [], total: 0, page: 1, size: 10 }
const skeletons = () => document.body.querySelectorAll('[data-testid="relation-skeleton"]')
function failing(error: unknown) {
return () => {
throw error
}
}
describe('Picker loading', () => {
it('shows three skeleton rows after 150 ms and announces Searching…', async () => {
await mountRelation('/content/events/42', { search: () => new Promise(() => undefined) })
await typeSearch('venue', 'zzz')
await settleSearch(vi)
expect(skeletons()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(skeletons()).toHaveLength(3)
expect(
text(document.body.querySelector('[data-testid="relation-empty"] [role="status"]')),
).toBe('Searching…')
expect(optionLabels()).toEqual([])
})
})
describe('No match', () => {
it('names the query and clears the search', async () => {
await mountRelation('/content/events/42', {
search: (key, query) => searchBody(key, query),
})
await openList('venue', vi, 'zzz')
expect(emptyText()).toBe('No Venues match "zzz" Clear search')
expect(liveText('venue')).toBe('No results')
button('Clear search')!.click()
await flushPromises()
await settleSearch(vi)
expect(relationInput('venue').value).toBe('')
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=1&size=10')
})
it('says the type has no entries at all', async () => {
await mountRelation('/content/events/42', { search: () => empty })
await openList('venue', vi)
expect(emptyText()).toBe('No Venues yet. Add one in Content, then come back.')
})
})
describe('Search failures', () => {
it.each([
['500', new ApiError(500)],
['a network error', new NetworkError()],
['404', new ApiError(404)],
])(
'shows the load error on %s and sends only the search again on Try again',
async (_name, error) => {
let fail = true
await mountRelation('/content/events/42', {
search: (key, query) => {
if (fail) {
throw error
}
return searchBody(key, query)
},
})
await openList('venue', vi, 'rue')
expect(emptyText()).toBe(
"Couldn't load Venues Check your connection and try again. Try again",
)
fail = false
const before = calls.length
button('Try again')!.click()
await flushPromises()
expect(calls.slice(before)).toEqual([
'/v1/content-type/venues/entry?q=rue&page=1&size=10',
])
expect(optionLabels()).toHaveLength(3)
},
)
it('shows the rate limit countdown on 429', async () => {
await mountRelation('/content/events/42', { search: failing(new ApiError(429, 3)) })
await openList('venue', vi, 'rue')
expect(emptyText()).toBe('Too many requests Try again in 0:03')
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(emptyText()).toContain('Wait a moment before trying again.')
expect(button('Try again')).toBeDefined()
})
it('adds no local copy on 401', async () => {
await mountRelation('/content/events/42', { search: failing(new ApiError(401)) })
await openList('venue', vi, 'rue')
expect(emptyText()).toBe('')
})
})
describe('Load more', () => {
it('appends the next page', async () => {
const names = Array.from({ length: 12 }, (_, index) => `Hall ${index + 1}`)
await mountRelation('/content/events/42', {
search: (_key, query) => {
const page = Number(query.get('page'))
const slice = names.slice((page - 1) * 10, page * 10)
return {
items: slice.map((name, index) => ({
id: 100 + (page - 1) * 10 + index,
data: { name, city: '', details: '' },
relations: {},
})),
total: 12,
page,
size: 10,
}
},
})
await openList('venue', vi)
expect(optionLabels()).toHaveLength(10)
button('Load more')!.click()
await flushPromises()
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=2&size=10')
expect(optionLabels()).toHaveLength(12)
expect(button('Load more')).toBeUndefined()
})
})
describe('A missing target', () => {
it('shows a dashed chip and lets an optional field save with the same id', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
expect(chipLabels('venue')).toEqual(['Entry no longer exists'])
expect(fieldWrapper('venue').querySelector('[data-missing="true"]')).not.toBeNull()
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
'You can still save. The entry keeps the link until you change it.',
)
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites[0]!.body.data).toMatchObject({ venue: 3 })
})
it('refuses to save a required field with only a missing target', async () => {
await mountRelation('/content/events/42', {
venue: 3,
venueField: { required: true },
relations: { venue: { id: 3, missing: true } },
})
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
'Venue is required. Choose another to save.',
)
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites).toHaveLength(0)
expect(relationError('venue')).toBe('Choose a Venue.')
expect(document.activeElement).toBe(relationInput('venue'))
})
it('replaces the missing chip when another target is picked', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
})
})
@@ -388,7 +388,7 @@ describe('Saved fields are locked', () => {
expect(text(inspector())).toContain("Used in the API, so it can't change.")
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
expect(text(inspector())).toContain("Can't change after saving")
expect(all('[role="radio"]')).toHaveLength(7)
expect(all('[role="radio"]')).toHaveLength(8)
expect(tile('Date').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Date'),
@@ -76,6 +76,7 @@ const FIELD_KEYS = [
'min',
'max',
'options',
'relation',
]
async function editRecipes() {
@@ -57,6 +57,7 @@ const TILES = [
['Date', 'calendar', 'A calendar day, without a time'],
['Choice', 'list', 'One option from a list you define'],
['Media', 'image', 'One image from your media library'],
['Relation', 'link', "Links to another type's entries"],
]
const HINT =
@@ -70,14 +71,14 @@ async function addMediaField() {
await pickTile('Media')
}
describe('Seven type tiles', () => {
it('lists the seven tiles in order, each with its name and description', async () => {
describe('Eight type tiles', () => {
it('lists the eight tiles in order, each with its name and description', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
expect(labels).toHaveLength(7)
expect(labels).toHaveLength(8)
labels.forEach((label, index) => {
const [name, , description] = TILES[index]!
expect(text(label)).toBe(`${name} ${description}`)
@@ -112,7 +113,7 @@ describe('Seven type tiles', () => {
})
describe('Adding a media field', () => {
it('shows the New badge, an editable Auto key with its help and all seven tiles enabled', async () => {
it('shows the New badge, an editable Auto key with its help and all eight tiles enabled', async () => {
await mountAt('/schema/events')
await addMediaField()
@@ -123,7 +124,7 @@ describe('Adding a media field', () => {
expect(text(inspector())).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
expect(all('[role="radio"]')).toHaveLength(7)
expect(all('[role="radio"]')).toHaveLength(8)
expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
true,
)
@@ -198,17 +199,17 @@ describe('The inspector of a saved media field', () => {
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
})
it('selects Media, dims the six other tiles and makes them unselectable', async () => {
it('selects Media, dims the seven other tiles and makes them unselectable', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
expect(all('[role="radio"]')).toHaveLength(7)
expect(all('[role="radio"]')).toHaveLength(8)
expect(tile('Media').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Media'),
)
expect(others).toHaveLength(6)
expect(others).toHaveLength(7)
expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
true,
)
@@ -330,6 +331,7 @@ describe('Saving a media field', () => {
min: null,
max: null,
options: [],
relation: null,
})
})
@@ -354,6 +356,7 @@ describe('Saving a media field', () => {
min: null,
max: null,
options: [],
relation: null,
})
})
@@ -0,0 +1,537 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
button,
deletes,
entryPage,
failWithBody,
mountAt,
posts,
puts,
text,
unmountAll,
} from './schemaTestKit'
import {
addField,
all,
badgesOf,
byLabel,
chip,
click,
dialog,
el,
fieldKeyInput,
inspector,
inspectorTitle,
labelInput,
nameInput,
pickTile,
rowOf,
saveButton,
selectRow,
tile,
toggle,
typeInto,
} from './builderKit'
import { drawsIcon } from './mediaFieldKit'
import { allTypes, eventsWithRelations, speakersType, venuesType } from './relationKit'
import { customTypes } from './schemaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
beforeEach(() => {
Element.prototype.scrollIntoView = () => undefined
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
afterEach(async () => {
vi.restoreAllMocks()
await unmountAll()
})
const SENTENCES = {
one_to_one: 'Each Event has one Venue, and each Venue has one Event.',
one_to_many: 'Each Event has one Venue. A Venue has many Events.',
many_to_many: 'Each Event has many Venues, and each Venue has many Events.',
}
const types = () => [customTypes[0]!, venuesType, speakersType, customTypes[1]!]
function mountBuilder(path = '/schema/events', options: object = {}) {
return mountAt(path, { content: types, ...options })
}
const picker = () => el('[data-testid="relation-picker"]')!
const cards = () => all('[data-testid="relation-picker"] [role="radio"]')
const selectTrigger = () => picker().querySelector<HTMLElement>('button[role="combobox"]')!
const ownerSentence = () => text(el('[data-testid="relation-owner-sentence"]'))
const inverseSentence = () => text(el('[data-testid="relation-inverse-sentence"]'))
function openTrigger(trigger: Element) {
trigger.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
}
async function chooseOption(trigger: Element, label: string) {
openTrigger(trigger)
await flushPromises()
const option = all('[role="option"]').find((candidate) => text(candidate).startsWith(label))!
option.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
option.click()
await flushPromises()
}
async function addRelation(label = 'Venue') {
await addField(label)
await pickTile('Relation')
}
async function relateTo(name: string) {
await chooseOption(selectTrigger(), name)
}
describe('The type gallery', () => {
it('gains Relation as the last tile with the link icon, selectable like the others', async () => {
await mountBuilder()
await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
expect(labels).toHaveLength(8)
const last = labels[7]!
expect(text(last)).toBe("Relation Links to another type's entries")
expect(drawsIcon(last, 'link')).toBe(true)
expect(tile('Relation').disabled).toBe(false)
})
it('dims the other tiles on a saved relation', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
})
await selectRow('Venue')
expect(tile('Relation').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter((radio) =>
radio.closest('label')!.innerHTML.includes('opacity-40'),
)
expect(others).toHaveLength(7)
expect(text(inspector())).toContain("Can't change after saving")
})
})
describe('Adding a relation field', () => {
it('shows New, an editable Auto key, the unsaved chip and the relation controls in order', async () => {
await mountBuilder()
await addRelation()
expect(badgesOf('Venue')).toContain('New')
expect(fieldKeyInput()!.value).toBe('venue')
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).not.toBeNull()
expect(chip()).toBe('1 problem to fix')
expect(inspectorTitle()).toBe('Field: Venue')
const copy = text(inspector())
expect(copy.indexOf('Type')).toBeLessThan(copy.indexOf('Related content type'))
expect(copy.indexOf('Related content type')).toBeLessThan(
copy.indexOf('How are they connected?'),
)
expect(copy).toContain('Choose the content type this field points to.')
})
it('cannot send a relation without a related type', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
})
await addRelation()
await click(saveButton())
expect(puts).toHaveLength(0)
expect(text(inspector())).toContain('Choose a related content type.')
})
})
describe('The related content type select', () => {
it('lists every custom type by name with its key, Events included', async () => {
await mountBuilder()
await addRelation()
openTrigger(selectTrigger())
await flushPromises()
expect(all('[role="option"]').map(text)).toEqual([
'Events events',
'Venues venues',
'Speakers speakers',
'Recipes recipes',
])
})
it('sets the sentences of the preview from the chosen type', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(ownerSentence()).toBe('Each Event has one Venue')
expect(inverseSentence()).toContain('Each Venue has many Events')
})
})
describe('The three shape cards', () => {
it('shows the titles and sentences, with One to many selected by default', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(cards().map((card) => text(card.closest('label')))).toEqual([
`One to one ${SENTENCES.one_to_one}`,
`One to many ${SENTENCES.one_to_many}`,
`Many to many ${SENTENCES.many_to_many}`,
])
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'checked',
'unchecked',
])
expect(cards()[1]!.closest('label')!.className).toContain(
'has-data-[state=checked]:bg-(--rc-sand)',
)
})
it('is a radio group, one card at a time', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
await click(cards()[2])
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'unchecked',
'checked',
])
})
it('stacks on one column on mobile', async () => {
await mountBuilder('/schema/events', { width: MOBILE_WIDTH })
await addRelation()
const fieldset = cards()[0]!.closest('[class*="grid-cols"]')!
expect(fieldset.className).toContain('grid-cols-1')
})
})
describe('The preview of both sides', () => {
it.each([
['One to one', 'Each Event has one Venue', 'Each Venue has one Event'],
['One to many', 'Each Event has one Venue', 'Each Venue has many Events'],
['Many to many', 'Each Event has many Venues', 'Each Venue has many Events'],
])('follows the card %s', async (card, owner, inverse) => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
await click(cards().find((radio) => text(radio.closest('label')).startsWith(card)))
expect(ownerSentence()).toBe(owner)
expect(inverseSentence()).toContain(inverse)
})
it('shows the muted note and the board example cards for One to many', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(inverseSentence()).toContain('· shown on Venue as the field "Events"')
expect(el('[data-testid="relation-preview"]')!.className).toContain('bg-elevated')
expect(text(el('[data-testid="relation-example-owner"]'))).toBe(
'Event Venue Rue Verte hall',
)
expect(text(el('[data-testid="relation-example-related"]'))).toBe(
'Venue Events Open day Lime plaster workshop +1',
)
})
it('changes the kind through the selects of the sentences', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
const triggers = all('[data-testid="relation-preview"] button[role="combobox"]')
await chooseOption(triggers[0]!, 'many')
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'unchecked',
'checked',
])
await chooseOption(triggers[1]!, 'one')
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'checked',
'unchecked',
'unchecked',
])
})
it('sends nothing about the inverse side', async () => {
await mountBuilder('/schema/events', { entries: () => entryPage(0) })
await addRelation()
await relateTo('Venues')
await click(saveButton())
const fields = (puts[0]!.body as { fields: { relation: unknown }[] }).fields
expect(fields[7]!.relation).toEqual({ content_type: 'venues', kind: 'one_to_many' })
expect(JSON.stringify(puts[0]!.body)).not.toContain('inverse')
})
})
describe('Plural and names', () => {
it('draws singular and plural forms from the type names and the field label', async () => {
await mountBuilder()
await addRelation('Place')
await relateTo('Venues')
await click(cards()[2])
expect(ownerSentence()).toBe('Each Event has many Venues')
expect(inverseSentence()).toContain('Each Place has many Events')
})
})
describe('The PUT and POST body', () => {
it('sends the relation as content_type and kind and every other field with relation null', async () => {
await mountBuilder('/schema/events', { entries: () => entryPage(0) })
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(puts).toHaveLength(1)
const fields = (puts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[7]).toEqual({
key: 'venue',
label: 'Venue',
type: 'relation',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
relation: { content_type: 'venues', kind: 'one_to_many' },
})
expect(fields.slice(0, 7).every((field) => field.relation === null)).toBe(true)
})
it('sends the chosen kind in the POST body of a new type', async () => {
await mountBuilder('/schema/new')
await typeInto(nameInput(), 'Gallery')
await selectRow('Title')
await addRelation('Speakers')
await relateTo('Speakers')
await click(cards()[2])
await click(saveButton())
const fields = (posts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[1]!.relation).toEqual({ content_type: 'speakers', kind: 'many_to_many' })
})
it('makes a no-change round trip of a loaded relation', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
entries: () => entryPage(0),
})
await typeInto(nameInput(), 'Events and talks')
await click(saveButton())
const fields = (puts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[7]).toMatchObject({
key: 'venue',
relation: { content_type: 'venues', kind: 'one_to_many' },
})
expect(fields[8]).toMatchObject({
key: 'speakers',
relation: { content_type: 'speakers', kind: 'many_to_many' },
})
})
})
describe('The inspector of a saved relation', () => {
async function openSaved() {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
})
await selectRow('Venue')
}
it('locks the key, the tiles, the related type and the cards but not Label or Required', async () => {
await openSaved()
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(labelInput()!.disabled).toBe(false)
expect(selectTrigger().hasAttribute('disabled')).toBe(true)
expect(
cards().every(
(card) => card.hasAttribute('disabled') || card.hasAttribute('data-disabled'),
),
).toBe(true)
expect(cards()[1]!.getAttribute('data-state')).toBe('checked')
expect(text(el('[data-testid="relation-locked-note"]'))).toBe(
"The related type and the connection can't change after saving.",
)
expect(text(selectTrigger())).toContain('Venues')
await toggle('Required')
expect(chip()).toBe('Unsaved changes')
})
})
describe('Review before saving', () => {
async function reviewRelation(required: boolean, total = 12) {
await mountBuilder('/schema/events', { entries: () => entryPage(total) })
await addRelation()
await relateTo('Venues')
if (required) {
await toggle('Required')
}
await click(saveButton())
}
it('refuses a required relation on a type with entries without sending the PUT', async () => {
await reviewRelation(true)
expect(puts).toHaveLength(0)
expect(text(dialog())).toContain('Review changes to Events')
expect(text(el('[data-testid="change-title"]'))).toBe('Add Venue')
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
expect(text(el('[data-testid="change-text"]'))).toBe(
"Relation, required. A relation can't have a default, so the 12 existing entries would have no Venue. Make Venue optional to add it.",
)
expect((button('Back to editing') as HTMLButtonElement).disabled).toBe(false)
button('Apply changes')!.click()
await flushPromises()
expect(puts).toHaveLength(0)
})
it('shows the clean row for an optional relation and applies it', async () => {
await reviewRelation(false)
expect(text(el('[data-testid="change-text"]'))).toBe(
'Relation, optional. 12 entries are unaffected.',
)
expect(el('[data-testid="change-flag"]')).toBeNull()
await click(button('Apply changes'))
expect(puts).toHaveLength(1)
})
it('sends a required relation at once when the type has no entries', async () => {
await reviewRelation(true, 0)
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it('flags making a saved optional relation required', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
entries: () => entryPage(12),
})
await selectRow('Venue')
await toggle('Required')
await click(saveButton())
expect(text(el('[data-testid="change-flag"]'))).toBe('Entries may not fit')
expect((button('Apply changes') as HTMLButtonElement).hasAttribute('aria-disabled')).toBe(
false,
)
})
})
describe('The server answers on the builder', () => {
it('runs the existing refusal flow on 409 schema_incompatible', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
update: failWithBodyUpdate(409, 'schema_incompatible'),
})
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(text(dialog())).toContain('Nothing was changed')
})
it('shows the invalid-field failure on 422 and keeps the edits', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
update: failWithBodyUpdate(422, 'invalid_field'),
})
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(text(el('[role="alert"]'))).toContain('Check the fields marked in the list.')
expect(rowOf('Venue')).toBeDefined()
expect(selectTrigger()).not.toBeNull()
})
})
function failWithBodyUpdate(status: number, body: string) {
const respond = failWithBody(status, body)
return () => respond()
}
describe('Deleting a type that another type points at', () => {
it('shows the referenced refusal and keeps the type', async () => {
await mountAt('/schema/venues', {
content: () => allTypes(),
type: (key: string) => allTypes().find((type) => type.key === key),
entries: () => entryPage(0),
remove: failWithBody(409, 'content_type_referenced'),
})
await click(byLabel('More actions'))
await click(all('[role="menuitem"]').find((item) => text(item) === 'Delete content type'))
await click(
all('button', dialog()).find((candidate) =>
text(candidate).startsWith('Delete content type'),
),
)
expect(deletes).toEqual(['/v1/content-type/venues'])
expect(text(dialog())).toContain("You can't delete Venues yet")
expect(text(dialog())).toContain(
'Events has a field that points to Venues. Remove that field first.',
)
expect(dialog()).not.toBeNull()
})
})
describe('The builder is for owners and admins', () => {
it('keeps the forbidden state and renders no relation control', async () => {
await mountAt('/schema/events', { role: 'user', content: types })
expect(text(el('#dashboard-content'))).toContain("You don't have access to Schema")
expect(el('[data-testid="relation-picker"]')).toBeNull()
})
})
@@ -70,6 +70,7 @@ const EVENTS_BODY = {
min: null,
max: 80,
options: [],
relation: null,
},
{
key: 'starts_on',
@@ -81,6 +82,7 @@ const EVENTS_BODY = {
min: '2026-01-01',
max: '2027-12-31',
options: [],
relation: null,
},
{
key: 'category',
@@ -92,6 +94,7 @@ const EVENTS_BODY = {
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
relation: null,
},
],
}
@@ -160,7 +160,7 @@ describe('New type, desktop (design state 1)', () => {
expect(copy).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
expect(all('[role="radio"]')).toHaveLength(7)
expect(all('[role="radio"]')).toHaveLength(8)
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
'Text',
)
+254
View File
@@ -0,0 +1,254 @@
import { flushPromises } from '@vue/test-utils'
import type { RelationKind, RelationSummary } from '@/lib/relationField'
import {
answerWrite,
entry42,
fieldWrapper,
lookupNames,
mountEditor,
text,
} from './entryEditorKit'
import { apiField, customTypes, eventsFields, type SchemaMount } from './schemaTestKit'
export const SEARCH_DEBOUNCE_MS = 200
export const SEARCH_SIZE = 10
interface Row {
id: number
data: Record<string, unknown>
}
export const VENUES: Row[] = [
{ id: 3, data: { name: 'Rue Verte hall', city: 'Lyon', details: '120 seats' } },
{ id: 4, data: { name: 'Rue Verte yard', city: 'Lyon', details: 'outdoor' } },
{ id: 5, data: { name: 'Rue du Port studio', city: 'Nantes', details: '24 seats' } },
{ id: 6, data: { name: 'Quai Nord', city: 'Nantes', details: '' } },
]
export const SPEAKERS: Row[] = [
{ id: 11, data: { name: 'Ines Duarte' } },
{ id: 12, data: { name: 'Jonas Pereira' } },
{ id: 13, data: { name: 'Ana Costa' } },
{ id: 14, data: { name: 'Luc Moreau' } },
]
const ROWS: Record<string, Row[]> = { venues: VENUES, speakers: SPEAKERS }
const FIRST_FIELD: Record<string, string> = { venues: 'name', speakers: 'name' }
export const venuesType = {
...customTypes[1]!,
id: 4,
key: 'venues',
name: 'Venues',
fields: [
apiField('Name', 'name', 'text', { required: true, options: [] }),
apiField('City', 'city', 'text', { options: [] }),
apiField('Details', 'details', 'text', { options: [] }),
],
}
export const speakersType = {
...customTypes[1]!,
id: 5,
key: 'speakers',
name: 'Speakers',
fields: [apiField('Name', 'name', 'text', { required: true, options: [] })],
}
export function relationField(
label: string,
key: string,
target: string,
kind: RelationKind,
rest: object = {},
) {
return apiField(label, key, 'relation', {
options: [],
relation: { content_type: target, kind },
...rest,
})
}
export function eventsWithRelations(
venue: object = {},
speakers: object = {},
venueKind: RelationKind = 'one_to_many',
) {
return {
...customTypes[0]!,
fields: [
...eventsFields,
relationField('Venue', 'venue', 'venues', venueKind, venue),
relationField('Speakers', 'speakers', 'speakers', 'many_to_many', speakers),
],
}
}
export function allTypes(venue: object = {}, speakers: object = {}) {
return [eventsWithRelations(venue, speakers), venuesType, speakersType]
}
export function summaryFor(typeKey: string, id: number): RelationSummary {
const row = ROWS[typeKey]!.find((candidate) => candidate.id === id)!
const key = FIRST_FIELD[typeKey]!
return {
id,
content_type: typeKey,
first_field: { key, type: 'text', value: row.data[key] },
}
}
export const MISSING_VENUE: RelationSummary = { id: 3, missing: true }
export function entryWithRelations(
venue: number | null,
speakers: number[],
relations?: Record<string, RelationSummary | RelationSummary[]>,
) {
const shown = relations ?? {
...(venue === null ? {} : { venue: summaryFor('venues', venue) }),
...(speakers.length === 0
? {}
: { speakers: speakers.map((id) => summaryFor('speakers', id)) }),
}
return {
...entry42,
data: { ...entry42.data, venue, speakers },
relations: shown,
}
}
export function searchBody(typeKey: string, query: URLSearchParams) {
const needle = (query.get('q') ?? '').toLowerCase()
const page = Number(query.get('page') ?? '1')
const size = Number(query.get('size') ?? String(SEARCH_SIZE))
const matches = ROWS[typeKey]!.filter((row) =>
String(row.data[FIRST_FIELD[typeKey]!]).toLowerCase().includes(needle),
)
const items = matches.slice((page - 1) * size, page * size).map((row) => ({
...entry42,
id: row.id,
content_type: typeKey,
data: row.data,
relations: {},
}))
return { items, total: matches.length, page, size }
}
export function answerRelationWrite(write: {
method: string
url: string
body: Record<string, unknown>
}) {
const answered = answerWrite(write as never) as { data: Record<string, unknown> }
const data = answered.data
const venue = data.venue
const speakers = (data.speakers as number[] | undefined) ?? []
return {
...answered,
relations: {
...(typeof venue === 'number' ? { venue: summaryFor('venues', venue) } : {}),
...(speakers.length > 0
? { speakers: speakers.map((id) => summaryFor('speakers', id)) }
: {}),
},
}
}
export interface RelationMount extends SchemaMount {
venue?: number | null
speakers?: number[]
venueField?: object
speakersField?: object
relations?: Record<string, RelationSummary | RelationSummary[]>
search?: (typeKey: string, query: URLSearchParams) => unknown
}
export function mountRelation(path: string, options: RelationMount = {}) {
const {
venue = null,
speakers = [],
venueField,
speakersField,
relations,
search,
...rest
} = options
const types = allTypes(venueField, speakersField)
return mountEditor(path, {
type: (key) => types.find((type) => type.key === key),
content: () => types,
entry: () => entryWithRelations(venue, speakers, relations),
writeEntry: answerRelationWrite,
lookup: lookupNames,
list: (key, query) => (search ?? searchBody)(key, query),
...rest,
})
}
export function relationInput(key: string) {
return fieldWrapper(key).querySelector<HTMLInputElement>('input[role="combobox"]')!
}
export function chipLabels(key: string) {
return [...fieldWrapper(key).querySelectorAll('[data-slot="tagsItemText"]')].map((item) =>
text(item),
)
}
export function optionLabels() {
return [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].map((option) =>
text(option),
)
}
export async function typeSearch(key: string, value: string) {
const input = relationInput(key)
input.focus()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function settleSearch(vi: {
advanceTimersByTimeAsync: (ms: number) => Promise<unknown>
}) {
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + 10)
await flushPromises()
}
export function liveText(key: string) {
return text(fieldWrapper(key).querySelector('[data-testid="relation-live"]'))
}
export async function pickOption(label: string) {
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
(candidate) => text(candidate).startsWith(label),
)!
option.click()
await flushPromises()
}
export async function openList(key: string, vi: Parameters<typeof settleSearch>[0], value = '') {
await typeSearch(key, value)
await settleSearch(vi)
}
export function removeButtons(key: string) {
return [
...fieldWrapper(key).querySelectorAll<HTMLButtonElement>('[data-slot="tagsItemDelete"]'),
]
}
export async function press(key: string, name: string) {
relationInput(key).dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true }))
await flushPromises()
}
export function relationError(key: string) {
return text(fieldWrapper(key).querySelector('[role="alert"]'))
}
export function emptyText() {
return text(document.body.querySelector('[data-testid="relation-empty"]'))
}