849 lines
26 KiB
TypeScript
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)
|
|
})
|
|
})
|