feat: schema builder's create flow
This commit is contained in:
1 parent
f514146fed
commit
083cc9e23e
71 files changed
+7223
-61
No files matched your search
@@ -0,0 +1,194 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { button, mountAt, posts, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
all,
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
keyField,
|
||||
keyInput,
|
||||
main,
|
||||
nameInput,
|
||||
rows,
|
||||
saveButton,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const KEY_MESSAGE = 'Use 2 to 40 lowercase letters, digits or hyphens.'
|
||||
|
||||
function errorsOf(container: Element | null) {
|
||||
return [...(container?.querySelectorAll('[role="alert"]') ?? [])].map(text)
|
||||
}
|
||||
|
||||
function nameField() {
|
||||
return nameInput()!.closest<HTMLElement>('[data-orientation]')!
|
||||
}
|
||||
|
||||
async function unlockKey() {
|
||||
await click(keyField().querySelector('button[aria-label="Edit key"]'))
|
||||
}
|
||||
|
||||
describe('Type-level rules (design states 6 and 14)', () => {
|
||||
it.each([
|
||||
['', 'Give the content type a name.'],
|
||||
[' ', 'Give the content type a name.'],
|
||||
])(
|
||||
'shows the name message for %j with an alert icon and sends nothing',
|
||||
async (value, message) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await typeInto(nameInput(), value)
|
||||
await click(saveButton())
|
||||
|
||||
expect(errorsOf(nameField())).toEqual([message])
|
||||
expect(nameField().querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(posts).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['e', KEY_MESSAGE],
|
||||
['Events', KEY_MESSAGE],
|
||||
['my_type', KEY_MESSAGE],
|
||||
['-events', KEY_MESSAGE],
|
||||
['events--2', KEY_MESSAGE],
|
||||
['new', 'The key "new" is reserved. Choose a different key.'],
|
||||
])('shows the key message for %j and sends nothing', async (value, message) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await unlockKey()
|
||||
|
||||
await typeInto(keyInput(), value)
|
||||
await click(saveButton())
|
||||
|
||||
expect(errorsOf(keyField())).toEqual([message])
|
||||
expect(keyField().querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(posts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['ab', 'press-mentions', '2026-awards', 'a'.repeat(40)])(
|
||||
'sends the accepted key %j',
|
||||
async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'X')
|
||||
await unlockKey()
|
||||
await typeInto(keyInput(), key)
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body.key).toBe(key)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Type-level errors together (design state 6)', () => {
|
||||
async function reachState6() {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'x')
|
||||
await unlockKey()
|
||||
await typeInto(keyInput(), 'e')
|
||||
await typeInto(nameInput(), '')
|
||||
await click(byLabel('Remove'))
|
||||
}
|
||||
|
||||
it('shows the three messages, the empty list, the chip, and disables Save only', async () => {
|
||||
await reachState6()
|
||||
|
||||
expect(errorsOf(nameField())).toEqual(['Give the content type a name.'])
|
||||
expect(errorsOf(keyField())).toEqual([KEY_MESSAGE])
|
||||
const empty = el('[data-testid="field-empty"]')!
|
||||
expect(text(empty)).toContain('No fields yet')
|
||||
expect(text(empty)).toContain('A content type needs at least one field.')
|
||||
expect(empty.querySelector('.text-error')).not.toBeNull()
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(discardButton()!.disabled).toBe(false)
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain('Field settings')
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain(
|
||||
'Add a field, then choose its type and rules here.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reveals every error at once when Save is activated, then enables Save once fixed', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(byLabel('Remove'))
|
||||
await click(button('Add field'))
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(errorsOf(nameField())).toEqual([])
|
||||
expect(errorsOf(keyField())).toEqual([])
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
expect(saveButton()!.getAttribute('aria-disabled')).toBe('true')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(errorsOf(nameField())).toEqual(['Give the content type a name.'])
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(posts).toHaveLength(0)
|
||||
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'Title')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('When errors are shown', () => {
|
||||
it('shows none on a pristine form', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(all('[role="alert"]')).toHaveLength(0)
|
||||
expect(all('[aria-invalid="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows an error for the input being edited and for no other', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(nameInput(), '')
|
||||
|
||||
expect(errorsOf(main())).toEqual(['Give the content type a name.'])
|
||||
})
|
||||
|
||||
it('ties each error to its input with aria-invalid and aria-describedby', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'x')
|
||||
await typeInto(nameInput(), '')
|
||||
|
||||
const input = nameInput()!
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
const describedBy = input.getAttribute('aria-describedby')!
|
||||
expect(describedBy).not.toBe('')
|
||||
expect(text(document.getElementById(describedBy.split(' ')[0]!))).toBe(
|
||||
'Give the content type a name.',
|
||||
)
|
||||
})
|
||||
|
||||
it('marks a row with an error with an alert icon, not only a border', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'x')
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), '')
|
||||
|
||||
const invalid = rows().filter((row) => row.querySelector('[data-testid="field-row-alert"]'))
|
||||
expect(invalid).toHaveLength(1)
|
||||
expect(invalid[0]!.className).toContain('border-error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,727 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { button, failWithBody, mountAt, posts, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
addChoice,
|
||||
addChoiceInput,
|
||||
addField,
|
||||
all,
|
||||
byLabel,
|
||||
choiceValues,
|
||||
chip,
|
||||
click,
|
||||
defaultInput,
|
||||
el,
|
||||
fieldKeyInput,
|
||||
inspector,
|
||||
labelInput,
|
||||
limit,
|
||||
nameInput,
|
||||
pickTile,
|
||||
press,
|
||||
rowLabels,
|
||||
rows,
|
||||
saveButton,
|
||||
selectRow,
|
||||
switchOf,
|
||||
tile,
|
||||
toggle,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function fieldRoot(input: HTMLElement | null) {
|
||||
return input!.closest<HTMLElement>('[data-orientation]')!
|
||||
}
|
||||
|
||||
function errorsIn(container: Element | null) {
|
||||
return [...(container?.querySelectorAll('[role="alert"]') ?? [])].map(text)
|
||||
}
|
||||
|
||||
async function unlockFieldKey() {
|
||||
await click(fieldRoot(fieldKeyInput()).querySelector('button[aria-label="Edit key"]'))
|
||||
}
|
||||
|
||||
const KEY_RULE = 'Start with a letter. Use lowercase letters, digits and underscores.'
|
||||
|
||||
describe('Field list rows', () => {
|
||||
it('shows a grip, the type icon box, the label and the mono key of each row', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await pickTile('Date')
|
||||
await addField('Online')
|
||||
await pickTile('Yes or no')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Online'])
|
||||
const second = rows()[1]!
|
||||
expect(second.querySelector('[data-grip]')!.getAttribute('aria-label')).toBe(
|
||||
'Move Starts on',
|
||||
)
|
||||
expect(second.querySelector('[data-field-row] svg')).not.toBeNull()
|
||||
expect(text(second.querySelector('[data-testid="field-row-key"]'))).toBe('starts_on')
|
||||
})
|
||||
|
||||
it('shows the badges on an unselected row and the actions on the selected one only', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await addField('Second')
|
||||
|
||||
const first = rows()[0]!
|
||||
expect(text(first.querySelector('[data-testid="field-row-badges"]'))).toBe(
|
||||
'New Required Unique',
|
||||
)
|
||||
expect(first.querySelector('[data-action]')).toBeNull()
|
||||
const selected = rows()[1]!
|
||||
expect(selected.className).toContain('bg-(--rc-sand)')
|
||||
expect(
|
||||
[...selected.querySelectorAll('[data-action]')].map((node) =>
|
||||
node.getAttribute('aria-label'),
|
||||
),
|
||||
).toEqual(['Move up', 'Move down', 'Remove'])
|
||||
})
|
||||
|
||||
it('reads Untitled field and a dash while a field has no label or key', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
|
||||
const added = rows()[1]!
|
||||
expect(text(added.querySelector('[data-testid="field-row-label"]'))).toBe('Untitled field')
|
||||
expect(text(added.querySelector('[data-testid="field-row-key"]'))).toBe('–')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding a field', () => {
|
||||
it('appends a selected Text row, focuses the label and counts 2 of 50', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(rows()).toHaveLength(2)
|
||||
expect(rows()[1]!.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
||||
'Text',
|
||||
)
|
||||
expect(labelInput()!.value).toBe('')
|
||||
expect(document.activeElement).toBe(labelInput())
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('2 of 50')
|
||||
})
|
||||
|
||||
it('disables Add field at 50 fields', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
for (let count = 1; count < 50; count++) {
|
||||
await click(button('Add field'))
|
||||
}
|
||||
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('50 of 50')
|
||||
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field key from the label', () => {
|
||||
it.each([
|
||||
['Title', 'title_2'],
|
||||
['Starts on', 'starts_on'],
|
||||
['Éléments', 'elements'],
|
||||
['Ends on (date)', 'ends_on_date'],
|
||||
['2nd room', 'nd_room'],
|
||||
['a'.repeat(60), 'a'.repeat(40)],
|
||||
])('label %j gives the key %j with the Auto badge', async (label, key) => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await addField(label)
|
||||
|
||||
expect(fieldKeyInput()!.value).toBe(key)
|
||||
expect(fieldKeyInput()!.readOnly).toBe(true)
|
||||
expect(text(fieldRoot(fieldKeyInput()))).toContain('Auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field key rules (design state 7)', () => {
|
||||
it.each(['Title', '1st', 'a-b', 'a b', ''])('shows the key message for %j', async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Thing')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), key)
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([KEY_RULE])
|
||||
})
|
||||
|
||||
it.each(['starts_on', 'a', 'a'.repeat(40)])('accepts %j', async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Thing')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), key)
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([])
|
||||
expect(fieldKeyInput()!.maxLength).toBe(40)
|
||||
})
|
||||
|
||||
it('flags a key already used by an earlier field under the second field', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Second')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), 'title')
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([
|
||||
'Another field already uses this key.',
|
||||
])
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it('shows one error with the alert icon, the chip and a disabled Save for a hyphenated key', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Start date')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), 'start-date')
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([KEY_RULE])
|
||||
expect(fieldRoot(fieldKeyInput()).querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const TILES = [
|
||||
['Text', 'Short text, like a title or a name', 'type'],
|
||||
['Rich text', 'Long text with formatting, written in Markdown', 'pilcrow'],
|
||||
['Number', 'Whole or decimal numbers', 'hash'],
|
||||
['Yes or no', 'An on or off switch', 'toggle-left'],
|
||||
['Date', 'A calendar day, without a time', 'calendar'],
|
||||
['Choice', 'One option from a list you define', 'list'],
|
||||
] as const
|
||||
|
||||
describe('Type tiles', () => {
|
||||
it('shows six radio tiles with an icon, a name and a description', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(el('[role="radiogroup"]')).not.toBeNull()
|
||||
for (const [name, description, icon] of TILES) {
|
||||
const radio = tile(name)
|
||||
const label = radio.closest('label')!
|
||||
expect(text(label)).toBe(`${name} ${description}`)
|
||||
expect(label.querySelector('svg')).not.toBeNull()
|
||||
expect(icon).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the selected tile with the sand background', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await pickTile('Date')
|
||||
|
||||
const label = tile('Date').closest('label')!
|
||||
expect(tile('Date').getAttribute('aria-checked')).toBe('true')
|
||||
expect(label.className).toContain('has-data-[state=checked]:bg-(--rc-sand)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The inspector follows the type', () => {
|
||||
it.each([
|
||||
[
|
||||
'Text',
|
||||
'Shortest',
|
||||
'Longest',
|
||||
true,
|
||||
'characters',
|
||||
'text',
|
||||
'Counted in characters. Entries show a counter.',
|
||||
],
|
||||
[
|
||||
'Rich text',
|
||||
'Shortest',
|
||||
'Longest',
|
||||
false,
|
||||
'characters',
|
||||
'textarea',
|
||||
'Counted in characters. Entries show a counter.',
|
||||
],
|
||||
[
|
||||
'Number',
|
||||
'Lowest value',
|
||||
'Highest value',
|
||||
true,
|
||||
'',
|
||||
'number',
|
||||
'Entries outside the range are refused.',
|
||||
],
|
||||
[
|
||||
'Date',
|
||||
'Earliest date',
|
||||
'Latest date',
|
||||
true,
|
||||
'',
|
||||
'date',
|
||||
'Entries outside the range are refused.',
|
||||
],
|
||||
])('%s', async (name, low, high, unique, suffix, control, help) => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile(name)
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain(low)
|
||||
expect(copy).toContain(high)
|
||||
expect(copy).toContain(help)
|
||||
expect(copy.includes('characters')).toBe(suffix === 'characters')
|
||||
expect(switchOf('Unique values').disabled).toBe(!unique)
|
||||
expect(copy.includes('Not available for rich text.')).toBe(name === 'Rich text')
|
||||
const field =
|
||||
control === 'textarea'
|
||||
? el<HTMLTextAreaElement>('textarea')
|
||||
: (defaultInput() as HTMLInputElement)
|
||||
expect(field!.placeholder).toBe('No default')
|
||||
if (control !== 'textarea') {
|
||||
expect(defaultInput()!.type).toBe(control)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows no limits and a disabled Unique switch for Yes or no', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Yes or no')
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Yes or no fields have no limits.')
|
||||
expect(copy).toContain('Not available for yes or no fields.')
|
||||
expect(switchOf('Unique values').disabled).toBe(true)
|
||||
expect(copy).toContain('On by default')
|
||||
expect(copy).toContain('New entries start with this switch off when the field is left out.')
|
||||
expect(limit('min')).toBeNull()
|
||||
expect(defaultInput()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the choice list and no limits for Choice', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Limits come from the choices below.')
|
||||
expect(copy).toContain('Choices')
|
||||
expect(el('[data-testid="choice-list"]')).not.toBeNull()
|
||||
expect(switchOf('Unique values').disabled).toBe(false)
|
||||
expect(limit('min')).toBeNull()
|
||||
expect(el('button[role="combobox"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Changing the type of a new field', () => {
|
||||
it('keeps the label, key and Required, resets the rest, and starts a Choice with no choice', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Capacity')
|
||||
await pickTile('Number')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await typeInto(limit('min'), '1')
|
||||
await typeInto(limit('max'), '5000')
|
||||
await typeInto(defaultInput(), '20')
|
||||
expect(defaultInput()!.disabled).toBe(true)
|
||||
|
||||
await pickTile('Yes or no')
|
||||
|
||||
expect(labelInput()!.value).toBe('Capacity')
|
||||
expect(fieldKeyInput()!.value).toBe('capacity')
|
||||
expect(switchOf('Required').getAttribute('aria-checked')).toBe('true')
|
||||
expect(switchOf('Unique values').getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await pickTile('Choice')
|
||||
|
||||
expect(choiceValues()).toEqual([])
|
||||
expect(switchOf('Required').getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await pickTile('Number')
|
||||
|
||||
expect(limit('min')!.value).toBe('')
|
||||
expect(limit('max')!.value).toBe('')
|
||||
expect(defaultInput()!.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field rules', () => {
|
||||
async function ready(type: string) {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Thing')
|
||||
await pickTile(type)
|
||||
}
|
||||
|
||||
it('flags an empty label or spaces', async () => {
|
||||
await ready('Text')
|
||||
|
||||
await typeInto(labelInput(), ' ')
|
||||
|
||||
expect(errorsIn(fieldRoot(labelInput()))).toEqual(['Give the field a label.'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '6000', max: '5000' }, "Lowest value can't be above the highest value."],
|
||||
['Text', { min: '10', max: '3' }, "Shortest can't be longer than longest."],
|
||||
[
|
||||
'Date',
|
||||
{ min: '2027-12-31', max: '2026-01-01' },
|
||||
"Earliest date can't be after the latest date.",
|
||||
],
|
||||
['Text', { min: '2.5' }, 'Use a whole number, 0 or more.'],
|
||||
['Text', { min: '-1' }, 'Use a whole number, 0 or more.'],
|
||||
])('flags %s limits %j under the limits', async (type, values, message) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(limit(which as 'min' | 'max'), value)
|
||||
}
|
||||
|
||||
expect(errorsIn(fieldRoot(limit('min')))).toEqual([message])
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '1', max: '5000', default: '0' }],
|
||||
['Text', { max: '3', default: 'abcd' }],
|
||||
])('flags a default outside the limits for %s', async (type, values) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(
|
||||
which === 'default' ? defaultInput() : limit(which as 'min' | 'max'),
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
expect(errorsIn(fieldRoot(defaultInput()))).toEqual([
|
||||
'The default value is outside the limits.',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '1', max: '5000' }],
|
||||
['Number', { min: '5', max: '5' }],
|
||||
['Text', { min: '0' }],
|
||||
])('accepts %s limits %j', async (type, values) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(limit(which as 'min' | 'max'), value)
|
||||
}
|
||||
|
||||
expect(errorsIn(inspector())).toEqual([])
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('flags a Choice field with no choice under the choice list', async () => {
|
||||
await ready('Choice')
|
||||
await addChoice('Talk')
|
||||
await click(byLabel('Remove Talk'))
|
||||
|
||||
expect(errorsIn(el('[data-testid="choice-list"]'))).toEqual(['Add at least one choice.'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unique and default', () => {
|
||||
it('disables the default and drops it from the body while Unique is on, then brings it back', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(500, 'boom') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(defaultInput(), 'Draft')
|
||||
|
||||
await toggle('Unique values')
|
||||
|
||||
expect(defaultInput()!.disabled).toBe(true)
|
||||
expect(text(inspector())).toContain("A field with unique values can't have a default.")
|
||||
await click(saveButton())
|
||||
expect(posts[0]!.body.fields).toMatchObject([{ unique: true, default: null }])
|
||||
|
||||
await toggle('Unique values')
|
||||
expect(defaultInput()!.disabled).toBe(false)
|
||||
expect(defaultInput()!.value).toBe('Draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choice list editor (design state 8)', () => {
|
||||
async function withChoices() {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('Workshop')
|
||||
await addChoice('Meetup')
|
||||
}
|
||||
|
||||
it('shows the required marker, the count, the rows, the add input and the help', async () => {
|
||||
await withChoices()
|
||||
|
||||
const list = el('[data-testid="choice-list"]')!
|
||||
expect(text(list)).toContain('Choices *')
|
||||
expect(text(el('[data-testid="choice-count"]'))).toBe('3 of 50')
|
||||
expect(choiceValues()).toEqual(['Talk', 'Workshop', 'Meetup'])
|
||||
expect(all<HTMLInputElement>('[data-testid="choice-row"] input')[0]!.placeholder).toBe(
|
||||
'Choice',
|
||||
)
|
||||
expect(addChoiceInput()!.placeholder).toBe('Add a choice')
|
||||
expect(button('Add')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(list)).toContain(
|
||||
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.',
|
||||
)
|
||||
})
|
||||
|
||||
it('appends with the button or Enter, empties the input and keeps the focus', async () => {
|
||||
await withChoices()
|
||||
|
||||
await typeInto(addChoiceInput(), 'Panel')
|
||||
await click(button('Add'))
|
||||
expect(choiceValues()).toEqual(['Talk', 'Workshop', 'Meetup', 'Panel'])
|
||||
expect(addChoiceInput()!.value).toBe('')
|
||||
expect(document.activeElement).toBe(addChoiceInput())
|
||||
|
||||
await typeInto(addChoiceInput(), 'Social')
|
||||
await press(addChoiceInput(), 'Enter')
|
||||
expect(choiceValues().at(-1)).toBe('Social')
|
||||
expect(document.activeElement).toBe(addChoiceInput())
|
||||
})
|
||||
|
||||
it('removes a choice', async () => {
|
||||
await withChoices()
|
||||
|
||||
await click(byLabel('Remove Workshop'))
|
||||
|
||||
expect(choiceValues()).toEqual(['Talk', 'Meetup'])
|
||||
})
|
||||
|
||||
it('lists the choices in the default select and clears the default on remove or rename', async () => {
|
||||
await withChoices()
|
||||
const openSelect = () => press(el('button[role="combobox"]'), 'ArrowDown')
|
||||
await openSelect()
|
||||
const options = all('[role="option"]').map(text)
|
||||
expect(options).toEqual(['None', 'Talk', 'Workshop', 'Meetup'])
|
||||
await press(
|
||||
all('[role="option"]').find((option) => text(option) === 'Workshop'),
|
||||
'Enter',
|
||||
)
|
||||
expect(text(el('button[role="combobox"]'))).toContain('Workshop')
|
||||
|
||||
await typeInto(all<HTMLInputElement>('[data-testid="choice-row"] input')[1], 'Workshops')
|
||||
expect(text(el('button[role="combobox"]'))).toContain('None')
|
||||
|
||||
await openSelect()
|
||||
await press(
|
||||
all('[role="option"]').find((option) => text(option) === 'Meetup'),
|
||||
'Enter',
|
||||
)
|
||||
await click(byLabel('Remove Meetup'))
|
||||
expect(text(el('button[role="combobox"]'))).toContain('None')
|
||||
})
|
||||
|
||||
it('flags a blank row and a duplicate row with an alert icon, as one problem, and caps at 50', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await pickTile('Choice')
|
||||
for (const value of ['Talk', 'Workshop', 'Meetup', 'x', 'y']) {
|
||||
await addChoice(value)
|
||||
}
|
||||
const inputs = () => all<HTMLInputElement>('[data-testid="choice-row"] input')
|
||||
await typeInto(inputs()[3], ' ')
|
||||
await typeInto(inputs()[4], ' Talk ')
|
||||
|
||||
const rowsWithErrors = all('[data-testid="choice-row"]').map((row) => errorsIn(row))
|
||||
expect(rowsWithErrors[3]).toEqual(["Choices can't be blank."])
|
||||
expect(rowsWithErrors[4]).toEqual(['Two choices are the same.'])
|
||||
expect(all('[data-testid="choice-row"] [role="alert"] svg')).toHaveLength(2)
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(inputs()[0]!.getAttribute('aria-invalid')).not.toBe('true')
|
||||
expect(inputs()[4]!.getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('treats choices as different when they differ by case', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('talk')
|
||||
|
||||
expect(errorsIn(el('[data-testid="choice-list"]'))).toEqual([])
|
||||
})
|
||||
|
||||
it('disables Add at 50 choices', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
for (let index = 0; index < 50; index++) {
|
||||
await addChoice(`C${index}`)
|
||||
}
|
||||
|
||||
expect(text(el('[data-testid="choice-count"]'))).toBe('50 of 50')
|
||||
await typeInto(addChoiceInput(), 'more')
|
||||
expect((button('Add') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reordering choices', () => {
|
||||
it('moves with the keyboard grip and announces each step', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
for (const value of ['Talk', 'Workshop', 'Meetup']) {
|
||||
await addChoice(value)
|
||||
}
|
||||
const grip = () => byLabel('Move Workshop')!
|
||||
|
||||
await press(grip(), ' ')
|
||||
expect(text(el('[data-testid="choice-live"]'))).toBe(
|
||||
'Moving Workshop. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
)
|
||||
await press(grip(), 'ArrowUp')
|
||||
expect(text(el('[data-testid="choice-live"]'))).toContain(
|
||||
'Moving Workshop. Position 1 of 3.',
|
||||
)
|
||||
await press(grip(), ' ')
|
||||
|
||||
expect(choiceValues()).toEqual(['Workshop', 'Talk', 'Meetup'])
|
||||
expect(text(el('[data-testid="choice-live"]'))).toBe('Workshop dropped. Position 1 of 3.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Removing a field', () => {
|
||||
it('selects the next row, then the previous at the end, and counts one less', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
await selectRow('Starts on')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(rowLabels()).toEqual(['Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Ends on')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('2 of 50')
|
||||
expect(text(document.body)).not.toContain('Undo')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
|
||||
})
|
||||
|
||||
it('shows the empty state and Field settings when the list is empty', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
|
||||
expect(text(el('[data-testid="field-empty"]'))).toContain('No fields yet')
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain('Field settings')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('0 of 50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Moving fields with the buttons', () => {
|
||||
async function three() {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
await selectRow('Starts on')
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Move up', ['Starts on', 'Title', 'Ends on']],
|
||||
['Move down', ['Title', 'Ends on', 'Starts on']],
|
||||
])('%s reorders and keeps the selection', async (label, order) => {
|
||||
await three()
|
||||
|
||||
await click(byLabel(label))
|
||||
|
||||
expect(rowLabels()).toEqual(order)
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Starts on')
|
||||
})
|
||||
|
||||
it('keeps the focus on the pressed button while it stays enabled', async () => {
|
||||
await three()
|
||||
await addField('Fourth')
|
||||
await selectRow('Starts on')
|
||||
|
||||
await click(byLabel('Move down'))
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Ends on', 'Starts on', 'Fourth'])
|
||||
expect(document.activeElement).toBe(byLabel('Move down'))
|
||||
})
|
||||
|
||||
it('moves the focus to the other button when the pressed one becomes disabled', async () => {
|
||||
await three()
|
||||
|
||||
await click(byLabel('Move up'))
|
||||
|
||||
expect((byLabel('Move up') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(document.activeElement).toBe(byLabel('Move down'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Title', 'Move up'],
|
||||
['Ends on', 'Move down'],
|
||||
])('disables the end button for %s: %s', async (row, label) => {
|
||||
await three()
|
||||
|
||||
await selectRow(row)
|
||||
|
||||
expect((byLabel(label) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard reorder (design state 9)', () => {
|
||||
async function three() {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
}
|
||||
const grip = (label: string) => byLabel(`Move ${label}`)!
|
||||
|
||||
it('picks up with Space, moves with Up, drops with Space and announces each step', async () => {
|
||||
await three()
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toBe(
|
||||
'Moving Starts on. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
)
|
||||
expect(grip('Starts on').closest('li')!.className).toContain('outline-primary')
|
||||
expect(el('[data-testid="reorder-live"]')!.getAttribute('aria-live')).toBe('polite')
|
||||
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
expect(rowLabels()).toEqual(['Starts on', 'Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toContain(
|
||||
'Moving Starts on. Position 1 of 3.',
|
||||
)
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
expect(rowLabels()).toEqual(['Starts on', 'Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toBe('Starts on dropped. Position 1 of 3.')
|
||||
})
|
||||
|
||||
it('restores the order on Esc', async () => {
|
||||
await three()
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
await press(grip('Starts on'), 'Escape')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toContain('Reorder cancelled.')
|
||||
})
|
||||
|
||||
it('has no pointer drag on rows', async () => {
|
||||
await three()
|
||||
|
||||
for (const row of rows()) {
|
||||
expect(row.getAttribute('draggable')).toBeNull()
|
||||
expect(row.querySelector('[draggable]')).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
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, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
addField,
|
||||
all,
|
||||
byLabel,
|
||||
click,
|
||||
el,
|
||||
nameInput,
|
||||
pickTile,
|
||||
press,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const writeText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const toggle = () => el('[data-testid="api-preview-toggle"]')!
|
||||
const json = () => el('[data-testid="api-json"]')!.textContent!
|
||||
|
||||
async function openPreview() {
|
||||
await click(toggle())
|
||||
}
|
||||
|
||||
describe('API preview (design state 10)', () => {
|
||||
it('shows the collapsed bar with the first route and Copy JSON, key falling back to events', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
const bar = text(el('[data-testid="api-preview"]'))
|
||||
expect(bar).toContain('API preview')
|
||||
expect(bar).toContain('GET /v1/content-type/events/entry')
|
||||
expect(bar).toContain('Copy JSON')
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('opens on the five routes, the sample entry and the hints', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Press mentions')
|
||||
|
||||
await openPreview()
|
||||
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true')
|
||||
const panel = text(el('[data-testid="api-preview"]'))
|
||||
expect(panel).toContain('Where entries of this type live')
|
||||
expect(all('[data-testid="api-route"]').map(text)).toEqual([
|
||||
'GET /v1/content-type/press-mentions/entry',
|
||||
'POST /v1/content-type/press-mentions/entry',
|
||||
'GET /v1/content-type/press-mentions/entry/{id}',
|
||||
'PUT /v1/content-type/press-mentions/entry/{id}',
|
||||
'DELETE /v1/content-type/press-mentions/entry/{id}',
|
||||
])
|
||||
expect(panel).toContain("A sample entry. Values come from each field's type and default.")
|
||||
expect(panel).toContain(
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.',
|
||||
)
|
||||
expect(JSON.parse(json())).toEqual({
|
||||
id: 42,
|
||||
content_type: 'press-mentions',
|
||||
data: { title: 'Example text' },
|
||||
created_by: 3,
|
||||
updated_by: 3,
|
||||
created_at: '2026-10-15T11:02:00Z',
|
||||
updated_at: '2026-10-15T11:02:00Z',
|
||||
})
|
||||
expect(json()).toContain('\n "id": 42,')
|
||||
})
|
||||
|
||||
it('updates on every edit, reorder and removal', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
|
||||
await addField('Online')
|
||||
await pickTile('Yes or no')
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title', 'online'])
|
||||
expect(JSON.parse(json()).data.online).toBe(false)
|
||||
|
||||
await press(byLabel('Move Online'), ' ')
|
||||
await press(byLabel('Move Online'), 'ArrowUp')
|
||||
await press(byLabel('Move Online'), ' ')
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['online', 'title'])
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title'])
|
||||
})
|
||||
|
||||
it('skips a field whose key is invalid', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy JSON', () => {
|
||||
it('writes the exact sample, reads Copied for 2 seconds, then Copy JSON again', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
await click(el('[data-testid="api-copy"]'))
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(json())
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
|
||||
vi.advanceTimersByTime(1999)
|
||||
await flushPromises()
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copy JSON')
|
||||
})
|
||||
})
|
||||
|
||||
describe('API preview on mobile', () => {
|
||||
it('opens a sheet with the route, the JSON and a full-width Copy JSON that copies', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(el('[data-testid="api-row"]'))
|
||||
|
||||
expect(text(el('[role="dialog"]'))).toContain('API preview')
|
||||
expect(text(el('[data-testid="api-sheet-route"]'))).toBe(
|
||||
'GET /v1/content-type/events/entry',
|
||||
)
|
||||
const shown = el('[data-testid="api-sheet-json"]')!.textContent!
|
||||
expect(JSON.parse(shown).content_type).toBe('events')
|
||||
const copy = el('[data-testid="api-sheet-copy"]')!
|
||||
expect(copy.className).toContain('flex-1')
|
||||
expect(text(copy)).toBe('Copy JSON')
|
||||
|
||||
await click(copy)
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(shown)
|
||||
expect(text(copy)).toBe('Copied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,487 @@
|
||||
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 router from '@/router'
|
||||
import BuilderActions from '@/components/BuilderActions.vue'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import {
|
||||
button,
|
||||
calls,
|
||||
createdType,
|
||||
failWithBody,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
pending,
|
||||
posts,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
import {
|
||||
addField,
|
||||
all,
|
||||
buildEvents,
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
keyField,
|
||||
keyInput,
|
||||
labelInput,
|
||||
main,
|
||||
nameInput,
|
||||
rowLabels,
|
||||
saveButton,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const SAVE_FAILED_TITLE = "Couldn't save the content type"
|
||||
const SAVE_FAILED_TEXT =
|
||||
'Something went wrong on our side. Your changes are still here. Try again in a moment.'
|
||||
|
||||
const EVENTS_BODY = {
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
default: null,
|
||||
min: null,
|
||||
max: 80,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'starts_on',
|
||||
label: 'Starts on',
|
||||
type: 'date',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('Saving a valid type', () => {
|
||||
it('posts once the exact body, in list order, with no icon', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.url).toBe('/v1/content-type')
|
||||
expect(posts[0]!.body).toEqual(EVENTS_BODY)
|
||||
expect('icon' in posts[0]!.body).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving (design state 11, create)', () => {
|
||||
async function startSaving() {
|
||||
const gate = pending()
|
||||
await mountAt('/schema/new', { create: () => gate.promise })
|
||||
await buildEvents()
|
||||
await click(saveButton())
|
||||
return gate
|
||||
}
|
||||
|
||||
it('shows the chip with a loader, dims and disables the form, and sends one request only', async () => {
|
||||
await startSaving()
|
||||
|
||||
expect(chip()).toBe('Saving…')
|
||||
expect(el('[data-testid="builder-chip"] svg')).not.toBeNull()
|
||||
expect(saveButton()).toBeUndefined()
|
||||
const saving = button('Saving…') as HTMLButtonElement
|
||||
expect(saving.disabled).toBe(true)
|
||||
expect(saving.querySelector('svg')).not.toBeNull()
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.disabled).toBe(true)
|
||||
expect(keyInput()!.disabled).toBe(true)
|
||||
expect(labelInput()!.disabled).toBe(true)
|
||||
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(all<HTMLButtonElement>('[data-grip]').every((grip) => grip.disabled)).toBe(true)
|
||||
expect((byLabel('Remove') as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
saving.click()
|
||||
await flushPromises()
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves the form editable again once the request settles', async () => {
|
||||
const gate = await startSaving()
|
||||
gate.resolve(createdType(posts[0]!.body))
|
||||
await flushPromises()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Double activation while saving', () => {
|
||||
it('sends one POST however often Save is activated programmatically', async () => {
|
||||
const gate = pending()
|
||||
const mounted = await mountAt('/schema/new', { create: () => gate.promise })
|
||||
await buildEvents()
|
||||
await click(saveButton())
|
||||
const actions = mounted.findComponent(BuilderActions)
|
||||
|
||||
actions.vm.$emit('save')
|
||||
actions.vm.$emit('save')
|
||||
await flushPromises()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect((button('Saving…') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(actions.props('canSave')).toBe(false)
|
||||
expect(actions.props('canReveal')).toBe(false)
|
||||
|
||||
gate.resolve(createdType(posts[0]!.body))
|
||||
await flushPromises()
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created type reaching the store', () => {
|
||||
it('inserts the new type before the first later name and needs no second list request', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Newsletters')
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(useContentTypesStore().items.map((type) => type.name)).toEqual([
|
||||
'Events',
|
||||
'Newsletters',
|
||||
'Recipes',
|
||||
'Press mentions',
|
||||
])
|
||||
expect(el('nav a[href="/content/newsletters"]')).not.toBeNull()
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved (design state 12, create)', () => {
|
||||
it('toasts, adds the type to the store and the sidebar, replaces the URL and does not prompt', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => ({ ...createdType(body), fields: new Array(6) }),
|
||||
})
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain('Content type saved')
|
||||
expect(text(document.body)).toContain('Events now has 6 fields.')
|
||||
expect(text(document.body)).not.toContain('Leave without saving?')
|
||||
expect(el('nav a[href="/content/events"]')).not.toBeNull()
|
||||
const listed = all('[data-testid="schema-row"]').map((row) => text(row))
|
||||
expect(listed.some((row) => row.includes('Events'))).toBe(true)
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).not.toBe('/schema/new')
|
||||
})
|
||||
|
||||
it('reads "1 field" for a single field', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => ({ ...createdType(body), fields: new Array(1) }),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain('Events now has 1 field.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation failing after a created type', () => {
|
||||
async function saveWithBrokenNavigation() {
|
||||
vi.spyOn(router, 'replace').mockRejectedValue(new Error('chunk load failed'))
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
}
|
||||
|
||||
it('keeps the type, shows no failure and blocks a second POST', async () => {
|
||||
await saveWithBrokenNavigation()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(text(document.body)).not.toContain(SAVE_FAILED_TITLE)
|
||||
expect(chip()).toBe('')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(el('nav a[href="/content/events"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain('Content type saved')
|
||||
})
|
||||
|
||||
it('treats the form as editable again after the next edit', async () => {
|
||||
await saveWithBrokenNavigation()
|
||||
|
||||
await typeInto(nameInput(), 'Events 2')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A failed save then an edit', () => {
|
||||
it('returns to editing so Save works again', async () => {
|
||||
let attempts = 0
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
throw new Error('boom')
|
||||
}
|
||||
return createdType(body)
|
||||
},
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
expect(chip()).toBe('Save failed')
|
||||
|
||||
await typeInto(nameInput(), 'Events 2')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Key already taken (design state 14)', () => {
|
||||
async function reachTaken() {
|
||||
await mountAt('/schema/new', {
|
||||
create: failWithBody(409, 'content_type_key_taken'),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
}
|
||||
|
||||
it('shows the message under Key, makes it editable with focus, and counts one problem', async () => {
|
||||
await reachTaken()
|
||||
|
||||
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
|
||||
'The key "events" is already used by another content type. Choose a different key.',
|
||||
)
|
||||
expect(keyInput()!.readOnly).toBe(false)
|
||||
expect(document.activeElement).toBe(keyInput())
|
||||
expect(text(keyField())).not.toContain('Auto')
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
})
|
||||
|
||||
it('clears the error and enables Save when the key is edited', async () => {
|
||||
await reachTaken()
|
||||
|
||||
await typeInto(keyInput(), 'events-2')
|
||||
|
||||
expect(keyField().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The server refuses a key or a field', () => {
|
||||
it('shows the key message under Key for 400 invalid_content_type_key', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: failWithBody(400, 'invalid_content_type_key'),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
|
||||
'Use 2 to 40 lowercase letters, digits or hyphens.',
|
||||
)
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it('shows the banner for 422 invalid_field and keeps the edits and Save', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(422, 'invalid_field') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain('Check the fields marked in the list.')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the access page for 403', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(403, 'Only owners and admins') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the redirect to the API client for 401', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(401, '') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).not.toContain(SAVE_FAILED_TITLE)
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save failed (design state 13)', () => {
|
||||
it.each([
|
||||
['answers 500', failWithBody(500, 'boom')],
|
||||
['fails (network)', networkFailure],
|
||||
])(
|
||||
'shows the alert, keeps every edit and retries with the same body when the POST %s',
|
||||
async (_, failure) => {
|
||||
let respond = failure
|
||||
await mountAt('/schema/new', { create: (body) => respond() ?? body })
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain(SAVE_FAILED_TITLE)
|
||||
expect(text(main())).toContain(SAVE_FAILED_TEXT)
|
||||
expect(chip()).toBe('Save failed')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Category'])
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
|
||||
respond = () => undefined
|
||||
const first = posts[0]!.body
|
||||
await click(button('Try again'))
|
||||
|
||||
expect(posts).toHaveLength(2)
|
||||
expect(posts[1]!.body).toEqual(first)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Offline (design state 16)', () => {
|
||||
function setOnline(value: boolean) {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
}
|
||||
|
||||
it('shows the banner and the chip, disables Save, keeps edits, and sends nothing on reconnect', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain(
|
||||
"You're offline. Changes will be saved when you reconnect.",
|
||||
)
|
||||
expect(chip()).toBe('Waiting for connection')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
|
||||
setOnline(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).not.toContain("You're offline.")
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
expect(posts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Discard', () => {
|
||||
it('resets to the blank form, sends nothing and keeps the URL', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await buildEvents()
|
||||
|
||||
await click(discardButton())
|
||||
|
||||
expect(nameInput()!.value).toBe('')
|
||||
expect(keyInput()!.value).toBe('')
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(chip()).toBe('')
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(posts).toHaveLength(0)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving with unsaved edits', () => {
|
||||
const DIALOG_TITLE = 'Leave without saving?'
|
||||
|
||||
it('navigates without a prompt from a pristine form', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
expect(text(document.body)).not.toContain(DIALOG_TITLE)
|
||||
})
|
||||
|
||||
it('asks first when something was edited, and stays on Keep editing', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
const leaving = router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain(DIALOG_TITLE)
|
||||
expect(button('Keep editing')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement)
|
||||
await click(button('Keep editing'))
|
||||
await leaving
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
})
|
||||
|
||||
it('leaves on Discard changes', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
|
||||
const leaving = router.push('/schema')
|
||||
await flushPromises()
|
||||
await click(button('Discard changes'))
|
||||
await leaving
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { button, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import { byLabel, click, el, labelInput, rowLabels, rows, typeInto } from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const dialog = () => el('[role="dialog"]')
|
||||
|
||||
describe('Selecting a field on mobile', () => {
|
||||
it('opens a sheet titled with the field, with the inspector, the move and remove buttons and Done', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(rows()[0]!.querySelector('[data-field-row]'))
|
||||
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Title')
|
||||
expect(labelInput()!.value).toBe('Title')
|
||||
expect(byLabel('Move up')).not.toBeNull()
|
||||
expect(byLabel('Move down')).not.toBeNull()
|
||||
expect(byLabel('Remove')).not.toBeNull()
|
||||
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(document.activeElement).toBe(el('[data-testid="field-sheet-title"]'))
|
||||
})
|
||||
|
||||
it('closes with Done and returns the focus to the row', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
const row = rows()[0]!.querySelector<HTMLElement>('[data-field-row]')!
|
||||
await click(row)
|
||||
|
||||
await click(button('Done'))
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(document.activeElement).toBe(row)
|
||||
})
|
||||
|
||||
it('updates the title with the label and removes the field from the sheet', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
await click(rows()[0]!.querySelector('[data-field-row]'))
|
||||
|
||||
await typeInto(labelInput(), 'Headline')
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Headline')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(rowLabels()).toEqual([])
|
||||
})
|
||||
|
||||
it('opens the sheet on a new field with the label focused', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Untitled field')
|
||||
expect(rowLabels()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { button, calls, failWith, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
all,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
inspector,
|
||||
keyField,
|
||||
keyInput,
|
||||
labelInput,
|
||||
main,
|
||||
nameInput,
|
||||
rowLabels,
|
||||
saveButton,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const NOT_FOUND_TITLE = 'Content type not found'
|
||||
const ACCESS_TITLE = "You don't have access to Schema"
|
||||
|
||||
function newTypeButtons() {
|
||||
return all('button').filter((candidate) => text(candidate) === 'New content type')
|
||||
}
|
||||
|
||||
describe('Reaching the builder', () => {
|
||||
it('opens from the header button', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
await click(newTypeButtons()[0])
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
|
||||
})
|
||||
|
||||
it('opens from the empty-state button', async () => {
|
||||
await mountAt('/schema', { content: () => [] })
|
||||
|
||||
await click(newTypeButtons().at(-1))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens from the mobile floating button', async () => {
|
||||
await mountAt('/schema', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(newTypeButtons().find((candidate) => candidate.className.includes('fixed')))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each(['/schema/events', '/schema/unknown'])('still says not found at %s', async (path) => {
|
||||
await mountAt(path)
|
||||
|
||||
expect(text(main())).toContain(NOT_FOUND_TITLE)
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles and sessions', () => {
|
||||
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
|
||||
await mountAt('/schema/new', { role })
|
||||
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(text(main())).not.toContain(ACCESS_TITLE)
|
||||
})
|
||||
|
||||
it('tells a verified user who is neither to go home, leaving the URL', async () => {
|
||||
await mountAt('/schema/new', { role: 'user' })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
expect(nameInput()).toBeNull()
|
||||
expect(el('[data-testid="field-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the same page when the content types answer 403', async () => {
|
||||
await mountAt('/schema/new', { content: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The builder needs no built-in list', () => {
|
||||
it('calls the custom list once, never the built-in one, and shows no skeleton', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
expect(calls).not.toContain('/v1/builtin-type')
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(el('[data-testid="schema-skeleton-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the builder while the custom list is still loading', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((done) => (release = done))
|
||||
await mountAt('/schema/new', { content: () => gate.then(() => []) })
|
||||
|
||||
expect(nameInput()).not.toBeNull()
|
||||
release()
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
|
||||
describe('New type, desktop (design state 1)', () => {
|
||||
it('shows the breadcrumb, Name, Key and the list header', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(text(el('nav[aria-label="breadcrumb"]'))).toBe('Schema New content type')
|
||||
expect(el('nav[aria-label="breadcrumb"] a[href="/schema"]')).not.toBeNull()
|
||||
expect(all('nav[aria-label="breadcrumb"] a')).toHaveLength(1)
|
||||
|
||||
expect(nameInput()!.value).toBe('')
|
||||
expect(nameInput()!.placeholder).toBe('For example, Events')
|
||||
expect(nameInput()!.required).toBe(true)
|
||||
expect(nameInput()!.maxLength).toBe(100)
|
||||
expect(keyInput()!.value).toBe('')
|
||||
expect(keyInput()!.placeholder).toBe('events')
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(main())).toContain(
|
||||
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.",
|
||||
)
|
||||
expect(text(main())).toContain('Fields')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('1 of 50')
|
||||
})
|
||||
|
||||
it('shows the starter row and the inspector of Title', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
const row = el('[data-testid="field-row"]')!
|
||||
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('title')
|
||||
expect(row.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe('true')
|
||||
expect(text(row.querySelector('[data-testid="field-row-badges"]'))).toBe('New')
|
||||
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
|
||||
expect(labelInput()!.value).toBe('Title')
|
||||
expect(el<HTMLInputElement>('input[name="field-key"]')!.value).toBe('title')
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Auto')
|
||||
expect(copy).toContain(
|
||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
||||
)
|
||||
expect(all('[role="radio"]')).toHaveLength(6)
|
||||
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
||||
'Text',
|
||||
)
|
||||
expect(copy).toContain("An entry can't be saved with this field empty.")
|
||||
expect(copy).toContain('Unique values')
|
||||
expect(copy).toContain('Used when an entry is created and this field is left out.')
|
||||
expect(copy).toContain('Shortest')
|
||||
expect(copy).toContain('Longest')
|
||||
expect(copy).toContain('Counted in characters. Entries show a counter.')
|
||||
expect(all('[role="switch"]').map((toggle) => toggle.getAttribute('aria-checked'))).toEqual(
|
||||
['false', 'false'],
|
||||
)
|
||||
})
|
||||
|
||||
it('disables Save and Discard, shows no chip, no entries bar and a collapsed API bar', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(chip()).toBe('')
|
||||
expect(text(main())).not.toContain('entries use this content type')
|
||||
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(el('[data-testid="api-json"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('New type, mobile (design state 1)', () => {
|
||||
it('shows the topbar, stacked Name and Key, the list and the API preview row', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(el('a[href="/schema"][aria-label="Schema"]')).not.toBeNull()
|
||||
expect(text(el('[data-slot="title"]'))).toBe('New content type')
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(keyInput()).not.toBeNull()
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(text(el('[data-testid="api-row"]'))).toBe('API preview')
|
||||
expect(el('[data-testid="api-row"] svg')).not.toBeNull()
|
||||
expect(inspector()).toBeNull()
|
||||
expect(el('[data-testid="inspector-pane"]')).toBeNull()
|
||||
expect(discardButton()).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(saveButton()).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(el('[data-testid="api-preview"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The key follows the name until it is edited', () => {
|
||||
it.each([
|
||||
['Events', 'events', 'auto'],
|
||||
['Press mentions', 'press-mentions', 'auto'],
|
||||
['Café & Bar', 'cafe-bar', 'auto'],
|
||||
[' Job openings!', 'job-openings', 'auto'],
|
||||
['2026 Awards', '2026-awards', 'auto'],
|
||||
['!!!', '', 'empty'],
|
||||
['x'.repeat(60), 'x'.repeat(40), 'auto'],
|
||||
])('%j gives the key %j', async (name, key, mode) => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await typeInto(nameInput(), name)
|
||||
|
||||
expect(keyInput()!.value).toBe(key)
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(keyField()).includes('Auto')).toBe(mode === 'auto')
|
||||
})
|
||||
|
||||
it('unlocks with the pencil, keeps a typed key, and resets to the name', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(keyField().querySelector('button[aria-label="Edit key"]'))
|
||||
|
||||
expect(keyInput()!.readOnly).toBe(false)
|
||||
expect(text(keyField())).not.toContain('Auto')
|
||||
expect(button('Reset to match the name')).toBeInstanceOf(HTMLButtonElement)
|
||||
|
||||
await typeInto(keyInput(), 'agenda')
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
expect(keyInput()!.value).toBe('agenda')
|
||||
|
||||
await click(button('Reset to match the name'))
|
||||
|
||||
expect(keyInput()!.value).toBe('events-and-talks')
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(keyField())).toContain('Auto')
|
||||
})
|
||||
})
|
||||
@@ -178,7 +178,7 @@ describe('Schema built-in view: loading', () => {
|
||||
})
|
||||
|
||||
describe('Schema built-in view: unknown key', () => {
|
||||
it.each(['/schema/unknown', '/schema/events', '/schema/new'])(
|
||||
it.each(['/schema/unknown', '/schema/events'])(
|
||||
'shows the not-found page at %s without redirecting',
|
||||
async (path) => {
|
||||
await mountAt(path)
|
||||
@@ -204,7 +204,7 @@ describe('Schema built-in view: error', () => {
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/builtin-type failed. Check your connection and try again.',
|
||||
)
|
||||
expect(button('Try again')).toBeDefined()
|
||||
expect(button('Try again')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
|
||||
|
||||
respond = () => builtinTypes
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { button, text } from './schemaTestKit'
|
||||
|
||||
export function el<T extends HTMLElement = HTMLElement>(selector: string) {
|
||||
return document.body.querySelector<T>(selector)
|
||||
}
|
||||
|
||||
export function all<T extends HTMLElement = HTMLElement>(selector: string) {
|
||||
return [...document.body.querySelectorAll<T>(selector)]
|
||||
}
|
||||
|
||||
export async function typeInto(
|
||||
input: HTMLInputElement | HTMLTextAreaElement | null | undefined,
|
||||
value: string,
|
||||
) {
|
||||
input!.value = value
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function press(target: Element | null | undefined, key: string) {
|
||||
target!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function click(target: Element | null | undefined) {
|
||||
;(target as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export const nameInput = () => el<HTMLInputElement>('input[name="name"]')
|
||||
export const keyInput = () => el<HTMLInputElement>('input[name="key"]')
|
||||
export const keyField = () => keyInput()!.closest<HTMLElement>('[data-orientation]')!
|
||||
export const labelInput = () => el<HTMLInputElement>('input[name="label"]')
|
||||
export const fieldKeyInput = () => el<HTMLInputElement>('input[name="field-key"]')
|
||||
export const saveButton = () => button('Save') as HTMLButtonElement | undefined
|
||||
export const discardButton = () => button('Discard') as HTMLButtonElement | undefined
|
||||
export const byLabel = (label: string) => el<HTMLButtonElement>(`[aria-label="${label}"]`)
|
||||
export const rows = () => all('[data-testid="field-row"]')
|
||||
export const rowLabels = () =>
|
||||
rows().map((row) => text(row.querySelector('[data-testid="field-row-label"]')))
|
||||
export const chip = () => text(el('[data-testid="builder-chip"]'))
|
||||
export const main = () => el('#dashboard-content')!
|
||||
export const inspector = () => el('[data-testid="field-inspector"]')
|
||||
export const live = () => text(el('[data-testid="reorder-live"]'))
|
||||
|
||||
export async function addField(label: string) {
|
||||
await click(button('Add field'))
|
||||
await typeInto(labelInput(), label)
|
||||
}
|
||||
|
||||
export async function selectRow(label: string) {
|
||||
const row = rows().find(
|
||||
(candidate) => text(candidate.querySelector('[data-testid="field-row-label"]')) === label,
|
||||
)
|
||||
await click(row!.querySelector('[data-field-row]'))
|
||||
}
|
||||
|
||||
export function tile(name: string) {
|
||||
return all('[role="radio"]').find((radio) =>
|
||||
text(radio.closest('label')).startsWith(name),
|
||||
) as HTMLButtonElement
|
||||
}
|
||||
|
||||
export async function pickTile(name: string) {
|
||||
await click(tile(name))
|
||||
}
|
||||
|
||||
export function switchOf(label: string) {
|
||||
const found = all('[data-testid="field-inspector"] label').find((candidate) =>
|
||||
text(candidate).startsWith(label),
|
||||
)
|
||||
return document.getElementById(found!.getAttribute('for')!) as HTMLButtonElement
|
||||
}
|
||||
|
||||
export async function toggle(label: string) {
|
||||
await click(switchOf(label))
|
||||
}
|
||||
|
||||
export const limit = (which: 'min' | 'max') => el<HTMLInputElement>(`input[name="${which}"]`)
|
||||
export const defaultInput = () => el<HTMLInputElement>('input[name="default"]')
|
||||
export const addChoiceInput = () => el<HTMLInputElement>('input[aria-label="Add a choice"]')
|
||||
|
||||
export async function addChoice(value: string) {
|
||||
await typeInto(addChoiceInput(), value)
|
||||
await click(button('Add'))
|
||||
}
|
||||
|
||||
export function choiceValues() {
|
||||
return all<HTMLInputElement>('[data-testid="choice-row"] input').map((input) => input.value)
|
||||
}
|
||||
|
||||
export async function buildEvents() {
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await typeInto(limit('max'), '80')
|
||||
await addField('Starts on')
|
||||
await pickTile('Date')
|
||||
await typeInto(limit('min'), '2026-01-01')
|
||||
await typeInto(limit('max'), '2027-12-31')
|
||||
await addField('Category')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('Workshop')
|
||||
await addChoice('Meetup')
|
||||
}
|
||||
@@ -76,6 +76,16 @@ export const builtinTypes = [
|
||||
fields: key === 'articles' ? articleFields : new Array(count),
|
||||
}))
|
||||
|
||||
export function createdType(body: Record<string, unknown>) {
|
||||
return {
|
||||
id: 4,
|
||||
icon: null,
|
||||
created_at: '2026-10-19T09:30:00Z',
|
||||
updated_at: '2026-10-19T09:30:00Z',
|
||||
...body,
|
||||
}
|
||||
}
|
||||
|
||||
export function manyTypes(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...customTypes[0]!,
|
||||
@@ -92,12 +102,24 @@ export function failWith(status: number): Responder {
|
||||
}
|
||||
}
|
||||
|
||||
export function failWithBody(status: number, body: string): Responder {
|
||||
return () => {
|
||||
throw new ApiError(status, undefined, body)
|
||||
}
|
||||
}
|
||||
|
||||
export const networkFailure: Responder = () => {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
export interface PostedRequest {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SchemaMount {
|
||||
role?: string
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
width?: number
|
||||
content?: Responder
|
||||
builtin?: Responder
|
||||
@@ -107,9 +129,11 @@ export interface SchemaMount {
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
export const calls: string[] = []
|
||||
export const posts: PostedRequest[] = []
|
||||
|
||||
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
|
||||
calls.length = 0
|
||||
posts.length = 0
|
||||
localStorage.clear()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
@@ -130,10 +154,15 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
role: (options.role ?? 'owner') as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (url: string) => {
|
||||
const request = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body)) as PostedRequest['body']
|
||||
posts.push({ url, body })
|
||||
return (options.create ?? (() => createdType(body)))(body)
|
||||
}
|
||||
calls.push(url)
|
||||
if (url === '/v1/content-type') {
|
||||
return (options.content ?? (() => customTypes))()
|
||||
return (options.content ?? (() => [...customTypes]))()
|
||||
}
|
||||
if (url === '/v1/builtin-type') {
|
||||
return (options.builtin ?? (() => builtinTypes))()
|
||||
|
||||
Reference in new issue
Block a user