feat: relation field and picker
This commit is contained in:
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',
|
||||
)
|
||||
|
||||
@@ -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"]'))
|
||||
}
|
||||
Reference in new issue
Block a user