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

849 lines
26 KiB
TypeScript

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 { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { useContentTypesStore } from '@/stores/contentTypes'
import {
button,
calls,
eventsFields,
failWithBody,
mountAt,
networkFailure,
pending,
puts,
text,
unmountAll,
updatedType,
} from './schemaTestKit'
import {
addField,
all,
badgesOf,
byLabel,
chip,
click,
dialog,
discardButton,
el,
fieldKeyInput,
keyInput,
labelInput,
main,
makeUnsavedChanges,
nameInput,
notice,
rowLabels,
rows,
saveButton,
selectRow,
setMax,
typeInto,
undoButtons,
undoRows,
} 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 SAVED_AT = /^Saved at \d{2}:\d{2}$/
const FIELD_KEYS = [
'key',
'label',
'type',
'required',
'unique',
'default',
'min',
'max',
'options',
'relation',
]
async function editRecipes() {
await selectRow('Servings')
await typeInto(labelInput(), 'Portions')
}
describe('Saving a type that has no entries', () => {
it('sends one PUT with no review and exactly name, icon and fields', async () => {
await mountAt('/schema/recipes')
await editRecipes()
await click(saveButton())
expect(puts).toHaveLength(1)
expect(puts[0]!.url).toBe('/v1/content-type/recipes')
expect(dialog()).toBeNull()
const body = puts[0]!.body as {
name: string
icon: unknown
fields: Record<string, unknown>[]
}
expect(Object.keys(body)).toEqual(['name', 'icon', 'fields'])
expect(body.icon).toBeNull()
expect(body.fields.map((field) => field.label)).toEqual([
'Title',
'Portions',
'Vegetarian',
'Method',
])
expect(body.fields.every((field) => Object.keys(field).join() === FIELD_KEYS.join())).toBe(
true,
)
})
})
describe('The PUT body', () => {
it('keeps the loaded icon, sends no key, trims the name and keeps the saved keys and types', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), ' Events and talks ')
await click(saveButton())
expect(puts).toHaveLength(1)
const body = puts[0]!.body as {
name: string
icon: unknown
fields: Record<string, unknown>[]
}
expect(Object.keys(body)).toEqual(['name', 'icon', 'fields'])
expect(body.name).toBe('Events and talks')
expect(body.icon).toBe('files')
expect('key' in body).toBe(false)
expect(body.fields.map((field) => [field.key, field.type])).toEqual(
eventsFields.map((field) => [field.key, field.type]),
)
expect(body.fields[3]).toMatchObject({
key: 'online',
default: null,
options: [],
})
expect(body.fields[4]).toMatchObject({ min: 1, max: 5000 })
expect(body.fields[5]).toMatchObject({
options: ['Talk', 'Workshop', 'Meetup'],
})
})
it('keeps a null icon null and leaves undone removals out', async () => {
await mountAt('/schema/recipes')
await selectRow('Method')
await click(byLabel('Remove'))
await click(saveButton())
const body = puts[0]!.body as {
icon: unknown
fields: { key: string }[]
}
expect(body.icon).toBeNull()
expect(body.fields.map((field) => field.key)).toEqual(['title', 'servings', 'vegetarian'])
})
})
describe('Saving (design state 11, no entries)', () => {
async function startSaving() {
const gate = pending()
await mountAt('/schema/recipes', { update: () => gate.promise })
await editRecipes()
await selectRow('Method')
await click(byLabel('Remove'))
await click(saveButton())
return gate
}
it('shows the chip and the loader, dims and disables everything, and sends once', 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(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('Move up') as HTMLButtonElement).disabled).toBe(true)
expect((byLabel('Remove') as HTMLButtonElement).disabled).toBe(true)
expect(undoButtons().every((undo) => undo.disabled)).toBe(true)
saving.click()
await flushPromises()
expect(puts).toHaveLength(1)
})
it('ignores a second Save however it is activated', async () => {
const gate = pending()
const mounted = await mountAt('/schema/recipes', {
update: () => gate.promise,
})
await editRecipes()
await click(saveButton())
const actions = mounted.findComponent(BuilderActions)
actions.vm.$emit('save')
actions.vm.$emit('save')
await flushPromises()
expect(puts).toHaveLength(1)
})
})
describe('Saved (design state 12)', () => {
it('toasts, stays, clears the marks, locks the new fields and reads Saved at', async () => {
await mountAt('/schema/recipes')
await editRecipes()
await click(button('Add field'))
await typeInto(labelInput(), 'Notes')
await selectRow('Method')
await click(byLabel('Remove'))
await click(saveButton())
await flushPromises()
expect(text(document.body)).toContain('Content type saved')
expect(text(document.body)).toContain('Recipes now has 4 fields.')
expect(router.currentRoute.value.path).toBe('/schema/recipes')
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
expect(undoRows()).toEqual([])
expect(chip()).toMatch(SAVED_AT)
expect(saveButton()!.disabled).toBe(true)
expect(discardButton()!.disabled).toBe(true)
await selectRow('Notes')
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(el('[data-testid="field-inspector"] [data-testid="slug-lock"]')).not.toBeNull()
expect(text(el('[data-testid="field-inspector"]'))).toContain("Can't change after saving")
})
it('writes the answered type into the store, the list and the sidebar with no second GET', async () => {
await mountAt('/schema/events', {
update: (body) => ({
...updatedType('events', body),
name: 'Events and talks',
}),
})
await typeInto(nameInput(), 'Events and talks')
await click(saveButton())
await flushPromises()
const store = useContentTypesStore()
expect(store.items[0]!.name).toBe('Events and talks')
expect(el('nav a[href="/content/events"]')!.textContent).toContain('Events and talks')
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
await router.push('/schema')
await flushPromises()
expect(
all('[data-testid="schema-row"]').some((row) => text(row).includes('Events and talks')),
).toBe(true)
})
it('reads "1 field" for a single field', async () => {
await mountAt('/schema/recipes')
for (let count = 0; count < 3; count++) {
await click(byLabel('Remove'))
}
await click(saveButton())
await flushPromises()
expect(text(document.body)).toContain('Recipes now has 1 field.')
})
it('does not prompt when leaving afterwards', async () => {
await mountAt('/schema/recipes')
await editRecipes()
await click(saveButton())
await flushPromises()
await router.push('/schema')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
expect(text(document.body)).not.toContain('Leave without saving?')
})
it('goes back to Unsaved changes on an edit and Discard returns to the last saved state', async () => {
await mountAt('/schema/recipes')
await editRecipes()
await click(saveButton())
await flushPromises()
await typeInto(nameInput(), 'Dishes')
expect(chip()).toBe('Unsaved changes')
await click(discardButton())
expect(nameInput()!.value).toBe('Recipes')
expect(rowLabels()).toEqual(['Title', 'Portions', 'Vegetarian', 'Method'])
expect(chip()).toMatch(SAVED_AT)
})
})
describe('Save on a type with entries opens the review (group 3, state 1)', () => {
it('sends nothing yet and opens the dialog with the summary, the rows in order and the note', async () => {
await mountAt('/schema/events')
await makeUnsavedChanges()
await click(saveButton())
expect(puts).toHaveLength(0)
const open = dialog()!
expect(text(open)).toContain('Review changes to Events')
expect(text(open)).toContain('Apply them to save the content type.')
expect(text(el('[data-testid="review-summary"]'))).toBe('Add 2 fields, change 1, remove 1')
expect(text(open)).toContain(
'Events has 37 entries. Each one is checked against the new fields when you apply.',
)
expect(all('[data-testid="change-title"]').map(text)).toEqual([
'Add Speaker',
'Add Room',
'Change Capacity',
'Remove Description',
])
expect(all('[data-testid="change-text"]').map(text)).toEqual([
'Text, optional.',
'Choice, required, default "Main hall". Existing entries get the default, so they still fit.',
'Highest value goes from 5000 to 2000.',
'Entries stop returning its values.',
])
expect(all('[data-testid="change-flag"]').map(text)).toEqual([
'Entries may not fit',
'Removes data',
])
expect(text(open)).toContain(
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
)
expect(button('Back to editing')).toBeInstanceOf(HTMLButtonElement)
expect(button('Apply changes')).toBeInstanceOf(HTMLButtonElement)
})
it('opens as a bottom sheet on mobile', async () => {
await mountAt('/schema/events', { width: MOBILE_WIDTH })
await click(rows()[6]!.querySelector('[data-field-row]'))
await click(byLabel('Remove'))
await vi.waitFor(() => expect(el('[data-testid="field-sheet-title"]')).toBeNull())
await click(saveButton())
expect(dialog()).not.toBeNull()
expect(dialog()!.className).toContain('max-sm:')
expect(text(dialog())).toContain('Review changes to Events')
})
})
describe('When the review opens', () => {
it('sends a name-only edit straight away', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
await click(saveButton())
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it('sends a reorder-only edit straight away', async () => {
await mountAt('/schema/events')
await selectRow('Ends on')
await click(byLabel('Move up'))
await click(saveButton())
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it.each([
['a field added', async () => addField('Speaker')],
['a field changed', async () => setMax('2000')],
[
'a field removed',
async () => {
await selectRow('Description')
await click(byLabel('Remove'))
},
],
])('opens for %s', async (_, edit) => {
await mountAt('/schema/events')
await edit()
await click(saveButton())
expect(dialog()).not.toBeNull()
expect(puts).toHaveLength(0)
})
it('lists only the field change when the name changed too', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
await setMax('2000')
await click(saveButton())
expect(all('[data-testid="change-title"]').map(text)).toEqual(['Change Capacity'])
expect(text(el('[data-testid="review-summary"]'))).toBe('Change 1')
})
})
describe('The review title after a rename', () => {
it('reads the saved name, not the name loaded with the page', async () => {
await mountAt('/schema/events')
await typeInto(nameInput()!, 'Seminars')
await setMax('2000')
await click(saveButton())
await click(button('Apply changes'))
await flushPromises()
await setMax('3000')
await click(saveButton())
expect(text(dialog()!)).toContain('Review changes to Seminars')
})
})
describe('Back to editing from the review', () => {
it('closes with the button and keeps every edit', async () => {
await mountAt('/schema/events')
await makeUnsavedChanges()
await click(saveButton())
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(puts).toHaveLength(0)
expect(chip()).toBe('Unsaved changes')
expect(badgesOf('Speaker')).toContain('New')
expect(undoRows()).toHaveLength(1)
})
it('closes on Esc', async () => {
await mountAt('/schema/events')
await setMax('2000')
await click(saveButton())
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(puts).toHaveLength(0)
})
it('returns the focus to Save', async () => {
await mountAt('/schema/events')
await setMax('2000')
const save = saveButton()!
save.focus()
await click(save)
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(document.activeElement).toBe(save)
})
})
describe('Applying (group 3, state 2) and Applied', () => {
async function apply() {
const gate = pending()
await mountAt('/schema/events', { update: () => gate.promise })
await setMax('2000')
await click(saveButton())
await click(button('Apply changes'))
return gate
}
it('sends one PUT, reads Applying… with a loader and cannot be dismissed', async () => {
await apply()
expect(puts).toHaveLength(1)
const confirm = button('Applying…') as HTMLButtonElement
expect(confirm.disabled).toBe(true)
expect(confirm.querySelector('svg')).not.toBeNull()
expect((button('Back to editing') as HTMLButtonElement).disabled).toBe(true)
expect(dialog()!.getAttribute('aria-busy')).toBe('true')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(dialog()).not.toBeNull()
confirm.click()
await flushPromises()
expect(puts).toHaveLength(1)
})
it('keeps the focus on the title of the dialog', async () => {
await apply()
await vi.waitFor(() =>
expect(document.activeElement?.textContent).toBe('Review changes to Events'),
)
})
it('closes the dialog on success with the toast, the cleared marks and Saved at', async () => {
const gate = await apply()
gate.resolve(updatedType('events', puts[0]!.body))
await flushPromises()
expect(dialog()).toBeNull()
expect(text(document.body)).toContain('Content type saved')
expect(text(document.body)).toContain('Events now has 7 fields.')
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
expect(chip()).toMatch(SAVED_AT)
expect(notice()).toContain('37 entries')
expect(text(document.body)).not.toContain('Leave without saving?')
})
})
describe('Refused (design state 15 and group 3, state 3)', () => {
const REFUSAL_TEXT =
"RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries."
async function refused(
edit: () => Promise<void> = async () => {
await setMax('2000')
await selectRow('Description')
await click(byLabel('Remove'))
},
) {
await mountAt('/schema/events', {
update: failWithBody(409, 'schema_incompatible'),
})
await edit()
await click(saveButton())
await click(button('Apply changes'))
}
it('replaces the review with the refusal dialog that lists only the flagged change', async () => {
await refused()
const open = dialog()!
expect(text(open)).toContain('Nothing was changed')
expect(text(open)).toContain(REFUSAL_TEXT)
expect(text(open)).toContain('Your content type is as it was')
expect(text(open)).toContain('No field was added, changed or removed.')
expect(text(open)).toContain('The change that can cause this in your edit')
expect(all('[data-testid="change-title"]').map(text)).toEqual(['Change Capacity'])
expect(text(el('[data-testid="change-text"]'))).toBe(
'Highest value goes from 5000 to 2000. An entry may have a higher value.',
)
expect(text(el('[data-testid="change-flag"]'))).toBe('Possible cause')
expect(text(open)).not.toContain('Description')
expect(text(open)).toContain(
'This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use.',
)
expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement)
expect(button('Back to editing')).toBeInstanceOf(HTMLButtonElement)
expect(text(open)).not.toContain('37')
const store = useContentTypesStore()
expect(store.items[0]!.fields).toHaveLength(7)
expect((store.items[0]!.fields[4] as { max: number }).max).toBe(5000)
expect(nameInput()).not.toBeNull()
})
it('omits the cause section when no change is flagged', async () => {
await refused(async () => {
await selectRow('Description')
await click(byLabel('Remove'))
})
const open = dialog()!
expect(text(open)).toContain('Nothing was changed')
expect(text(open)).toContain('Your content type is as it was')
expect(text(open)).not.toContain('The change that can cause this in your edit')
expect(el('[data-testid="refusal-causes"]')).toBeNull()
expect(text(open)).toContain('This kind of change is refused')
})
it('returns to a banner with Review changes, marks the flagged row and keeps Save', async () => {
await refused()
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(text(main())).toContain('Nothing was changed')
expect(text(main())).toContain(
"Some entries don't fit the new fields, so the whole change was refused. RenewCMS can't tell which entries. The changes that can cause this are marked.",
)
expect(badgesOf('Capacity')).toContain('Changed')
expect(badgesOf('Capacity')).toContain('Possible cause')
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
await click(button('Review changes'))
expect(text(dialog())).toContain('Review changes to Events')
expect(all('[data-testid="change-title"]').map(text)).toEqual([
'Change Capacity',
'Remove Description',
])
})
it('also returns to the banner on Esc or the close icon', async () => {
await refused()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(text(main())).toContain('Nothing was changed')
})
it('resets the form on Discard changes, with no banner and no marker', async () => {
await refused()
await click(button('Discard changes'))
await vi.waitFor(() => expect(dialog()).toBeNull())
await flushPromises()
expect(text(main())).not.toContain('Nothing was changed')
expect(chip()).toBe('Saved')
expect(rowLabels()).toHaveLength(7)
expect(badgesOf('Capacity')).toBe('')
})
it('loses the marker when the flagged change is reverted, and the banner on success', async () => {
await refused()
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
await selectRow('Capacity')
await typeInto(el('input[name="max"]'), '5000')
expect(badgesOf('Capacity')).not.toContain('Possible cause')
expect(badgesOf('Description')).toBe('')
expect(text(main())).toContain('Nothing was changed')
})
it('clears the banner and every marker once a PUT succeeds', async () => {
let answer: (body: Record<string, unknown>) => unknown = () => {
throw Object.assign(new Error('refused'), {
status: 409,
body: 'schema_incompatible',
})
}
const { ApiError } = await import('@/composables/useApi')
answer = () => {
throw new ApiError(409, undefined, 'schema_incompatible')
}
await mountAt('/schema/events', { update: (body) => answer(body) })
await setMax('2000')
await click(saveButton())
await click(button('Apply changes'))
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
await selectRow('Ends on')
expect(badgesOf('Capacity')).toContain('Possible cause')
answer = (body) => updatedType('events', body)
await click(button('Review changes'))
await click(button('Apply changes'))
await flushPromises()
expect(text(main())).not.toContain('Nothing was changed')
expect(badgesOf('Capacity')).toBe('')
expect(chip()).toMatch(SAVED_AT)
})
})
describe('Other answers of the PUT', () => {
async function answered(failure: () => unknown, edit = async () => setMax('2000')) {
await mountAt('/schema/events', { update: failure })
await edit()
await click(saveButton())
if (dialog()) {
await click(button('Apply changes'))
}
}
it('closes the review and shows the banner for 422 invalid_field, keeping the edits', async () => {
await answered(failWithBody(422, 'invalid_field'))
expect(dialog()).toBeNull()
expect(text(main())).toContain('Check the fields marked in the list.')
expect(badgesOf('Capacity')).toContain('Changed')
expect(saveButton()!.disabled).toBe(false)
})
it('shows the name message for 400 and keeps the edits', async () => {
await answered(failWithBody(400, 'Bad request body'))
expect(dialog()).toBeNull()
expect(text(nameInput()!.closest('[data-orientation]'))).toContain(
'Give the content type a name.',
)
expect(badgesOf('Capacity')).toContain('Changed')
expect(puts).toHaveLength(1)
})
it('keeps the name message off a 400 that is about the key', async () => {
await answered(failWithBody(400, 'invalid_content_type_key'))
expect(dialog()).toBeNull()
expect(text(nameInput()!.closest('[data-orientation]'))).not.toContain(
'Give the content type a name.',
)
expect(badgesOf('Capacity')).toContain('Changed')
})
it('shows the not-found page for 404 and drops the type from the store', async () => {
await answered(failWithBody(404, 'content_type_not_found'))
expect(text(main())).toContain('Content type not found')
expect(useContentTypesStore().items.map((type) => type.key)).toEqual([
'recipes',
'press-mentions',
])
expect(nameInput()).toBeNull()
})
it('shows the access page for 403', async () => {
await answered(failWithBody(403, 'Only owners and admins'))
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 answered(failWithBody(401, ''))
expect(dialog()).toBeNull()
expect(text(main())).not.toContain(SAVE_FAILED_TITLE)
})
})
describe('Save failed (design state 13)', () => {
it.each([
['answers 500', failWithBody(500, 'boom')],
['fails (network)', networkFailure],
])(
'closes the review, keeps the edits and retries through the review when the PUT %s',
async (_, failure) => {
let respond: () => unknown = failure
await mountAt('/schema/events', {
update: (body) => respond() ?? body,
})
await setMax('2000')
await click(saveButton())
await click(button('Apply changes'))
expect(dialog()).toBeNull()
expect(text(main())).toContain(SAVE_FAILED_TITLE)
expect(text(main())).toContain(SAVE_FAILED_TEXT)
expect(chip()).toBe('Save failed')
expect(badgesOf('Capacity')).toContain('Changed')
expect(saveButton()!.disabled).toBe(false)
respond = () => undefined
const first = puts[0]!.body
await click(button('Try again'))
expect(dialog()).not.toBeNull()
expect(puts).toHaveLength(1)
await click(button('Apply changes'))
expect(puts).toHaveLength(2)
expect(puts[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 the edits and sends nothing on reconnect', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
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 and talks')
setOnline(true)
await flushPromises()
expect(text(document.body)).not.toContain("You're offline.")
expect(saveButton()!.disabled).toBe(false)
expect(puts).toHaveLength(0)
})
it('disables Apply changes while the review is open offline and keeps the edits', async () => {
await mountAt('/schema/events')
await setMax('2000')
await click(saveButton())
setOnline(false)
await flushPromises()
expect((button('Apply changes') as HTMLButtonElement).disabled).toBe(true)
expect(badgesOf('Capacity')).toContain('Changed')
setOnline(true)
await flushPromises()
expect((button('Apply changes') as HTMLButtonElement).disabled).toBe(false)
})
})
describe('Accessibility of the edit mode', () => {
it('keeps Undo a reachable button', async () => {
await mountAt('/schema/events')
await selectRow('Description')
await click(byLabel('Remove'))
const undo = undoButtons()[0]!
expect(undo.tagName).toBe('BUTTON')
expect(undo.tabIndex).toBe(0)
expect(undo.getAttribute('type')).toBe('button')
})
it('ties the lock icons to a text or a help', async () => {
await mountAt('/schema/events')
expect(keyInput()!.readOnly).toBe(true)
expect(keyInput()!.closest('[data-orientation]')).not.toBeNull()
await selectRow('Starts on')
expect(text(el('[data-testid="field-inspector"]'))).toContain(
"Used in the API, so it can't change.",
)
expect(text(el('[data-testid="field-inspector"]'))).toContain("Can't change after saving")
})
it('names the dialog and keeps the focus inside it', async () => {
await mountAt('/schema/events')
await setMax('2000')
await click(saveButton())
const open = dialog()!
expect(open.getAttribute('role')).toBe('alertdialog')
expect(open.getAttribute('aria-labelledby')).not.toBeNull()
expect(open.contains(document.activeElement)).toBe(true)
})
})