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,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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user