Files
RenewCMS/web/src/views/__tests__/SchemaBuilderFields.spec.ts
T

728 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
})
})