feat: delete a custom type
This commit is contained in:
1 parent
76e9e1220c
commit
5404291af2
25 files changed
+1768
-46
No files matched your search
@@ -0,0 +1,583 @@
|
||||
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 { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import {
|
||||
button,
|
||||
calls,
|
||||
deletes,
|
||||
entryPage,
|
||||
failWith,
|
||||
failWithBody,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
pending,
|
||||
puts,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
import {
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
dialog,
|
||||
el,
|
||||
main,
|
||||
nameInput,
|
||||
press,
|
||||
setMax,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const MORE = 'More actions'
|
||||
const DELETE = 'Delete content type'
|
||||
const CONFIRM_TEXT =
|
||||
"Recipes has no entries. Its fields and its API routes will be deleted. This can't be undone."
|
||||
const FAILED_TITLE = "Couldn't delete the content type"
|
||||
const FAILED_TEXT = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
|
||||
|
||||
const more = () => byLabel(MORE) as HTMLButtonElement | null
|
||||
const menuItem = () =>
|
||||
[...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(
|
||||
(item) => text(item) === DELETE,
|
||||
)
|
||||
const dialogButton = (label: string) =>
|
||||
[...(dialog()?.querySelectorAll('button') ?? [])].find((candidate) =>
|
||||
text(candidate).startsWith(label),
|
||||
) as HTMLButtonElement | undefined
|
||||
|
||||
async function chooseDelete() {
|
||||
await click(more())
|
||||
await click(menuItem())
|
||||
}
|
||||
|
||||
async function openConfirm(options = {}) {
|
||||
await mountAt('/schema/recipes', options)
|
||||
await chooseDelete()
|
||||
}
|
||||
|
||||
describe('The overflow menu', () => {
|
||||
it('holds one error-coloured Delete content type item after Save on desktop', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
|
||||
const buttons = [...document.body.querySelectorAll('[data-testid="schema-builder"] button')]
|
||||
expect(buttons.indexOf(more()!)).toBeGreaterThan(buttons.indexOf(button('Save')!))
|
||||
await click(more())
|
||||
|
||||
const items = [...document.body.querySelectorAll('[role="menuitem"]')]
|
||||
expect(items.map(text)).toEqual([DELETE])
|
||||
expect(items[0]!.className).toContain('text-error')
|
||||
expect(items[0]!.querySelector('svg.iconify--lucide')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('is the topbar action on mobile', async () => {
|
||||
await mountAt('/schema/recipes', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(more()).not.toBeNull()
|
||||
expect(more()!.closest('[data-testid="schema-builder"]')).toBeNull()
|
||||
expect(document.getElementById(TOPBAR_ACTION_ID)!.contains(more())).toBe(true)
|
||||
expect(document.body.querySelectorAll(`[aria-label="${MORE}"]`)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/schema/new', 'no overflow menu on a new type'],
|
||||
['/schema/articles', 'no overflow menu on a built-in type'],
|
||||
])('has no menu on %s', async (path) => {
|
||||
await mountAt(path)
|
||||
|
||||
expect(more()).toBeNull()
|
||||
})
|
||||
|
||||
it('has no menu for a user who is neither owner nor admin', async () => {
|
||||
await mountAt('/schema/recipes', { role: 'user' })
|
||||
|
||||
expect(more()).toBeNull()
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
})
|
||||
|
||||
it('is enabled on a saved type and disabled while the PUT is in flight', async () => {
|
||||
const gate = pending()
|
||||
await mountAt('/schema/events', { update: () => gate.promise })
|
||||
expect(more()!.disabled).toBe(false)
|
||||
await setMax('2000')
|
||||
await click(button('Save'))
|
||||
await click(button('Apply changes'))
|
||||
|
||||
expect(more()!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled while a delete is in flight', async () => {
|
||||
const gate = pending()
|
||||
await openConfirm({ remove: () => gate.promise })
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
expect(more()!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes with Esc and returns the focus to the button', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await click(more())
|
||||
expect(menuItem()).toBeDefined()
|
||||
|
||||
await press(document.activeElement ?? document.body, 'Escape')
|
||||
await vi.waitFor(() => expect(menuItem()).toBeUndefined())
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(more()))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete blocked, the type has entries (state 5)', () => {
|
||||
it('opens the blocked dialog with no request and no delete button', async () => {
|
||||
await mountAt('/schema/events')
|
||||
calls.length = 0
|
||||
await chooseDelete()
|
||||
|
||||
expect(deletes).toHaveLength(0)
|
||||
expect(calls).toEqual([])
|
||||
const content = text(dialog())
|
||||
expect(content).toContain("You can't delete Events yet")
|
||||
expect(content).toContain('A content type can only be deleted when it has no entries.')
|
||||
expect(content).toContain('Events has 37 entries')
|
||||
expect(content).toContain('Delete all of its entries first, then delete the content type.')
|
||||
expect(dialogButton(DELETE)).toBeUndefined()
|
||||
expect(dialogButton('Close')).toBeDefined()
|
||||
expect(dialogButton('View entries')).toBeDefined()
|
||||
expect(dialog()!.querySelector('svg.iconify--lucide')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('groups the count like the info bar', async () => {
|
||||
await mountAt('/schema/events', { entries: () => entryPage(1200) })
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain('Events has 1,200 entries')
|
||||
})
|
||||
|
||||
it('says "1 entry" for a single entry', async () => {
|
||||
await mountAt('/schema/recipes', { entries: () => entryPage(1) })
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain('Recipes has 1 entry')
|
||||
})
|
||||
|
||||
it('opens with the focus on the dialog and not on View entries', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await chooseDelete()
|
||||
|
||||
expect(dialog()!.contains(document.activeElement)).toBe(true)
|
||||
expect(document.activeElement).not.toBe(dialogButton('View entries'))
|
||||
})
|
||||
|
||||
it.each(['Close', 'icon', 'Escape'])(
|
||||
'closes with %s, keeps the edits and returns the focus to the menu button',
|
||||
async (way) => {
|
||||
await mountAt('/schema/events')
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
await chooseDelete()
|
||||
|
||||
if (way === 'Close') {
|
||||
await click(dialogButton('Close'))
|
||||
} else if (way === 'icon') {
|
||||
await click(dialog()!.querySelector('button[aria-label="Close"]'))
|
||||
} else {
|
||||
await press(dialog(), 'Escape')
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(nameInput()!.value).toBe('Events and talks')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(document.activeElement).toBe(more())
|
||||
expect(deletes).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('goes to the entries of the type with View entries', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await chooseDelete()
|
||||
|
||||
await click(dialogButton('View entries'))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/content/events'))
|
||||
expect(dialog()).toBeNull()
|
||||
expect(deletes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows the saved count and never mentions the edits', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await setMax('2000')
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain('Events has 37 entries')
|
||||
expect(text(dialog())).not.toMatch(/unsaved|changes/i)
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete confirm, the type has no entries (state 6)', () => {
|
||||
it('opens with the copy, the key in mono, Cancel and the error action', async () => {
|
||||
await openConfirm()
|
||||
|
||||
expect(deletes).toHaveLength(0)
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete Recipes?')
|
||||
expect(content).toContain(CONFIRM_TEXT)
|
||||
expect(content).toContain('Key: recipes')
|
||||
expect(dialog()!.querySelector('.font-mono')?.textContent).toBe('recipes')
|
||||
expect(dialogButton('Cancel')).toBeDefined()
|
||||
expect(dialogButton(DELETE)).toBeDefined()
|
||||
})
|
||||
|
||||
it('never gives the destructive button the first focus', async () => {
|
||||
await openConfirm()
|
||||
|
||||
expect(dialog()!.contains(document.activeElement)).toBe(true)
|
||||
expect(document.activeElement).not.toBe(dialogButton(DELETE))
|
||||
})
|
||||
|
||||
it.each(['Cancel', 'icon', 'Escape'])(
|
||||
'%s closes it with nothing sent and the focus back on the menu button',
|
||||
async (way) => {
|
||||
await openConfirm()
|
||||
|
||||
if (way === 'Cancel') {
|
||||
await click(dialogButton('Cancel'))
|
||||
} else if (way === 'icon') {
|
||||
await click(dialog()!.querySelector('button[aria-label="Close"]'))
|
||||
} else {
|
||||
await press(dialog(), 'Escape')
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(deletes).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(more())
|
||||
},
|
||||
)
|
||||
|
||||
it('shows the saved count even with unsaved edits and leaves them alone on Cancel', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await typeInto(nameInput(), 'Recipes!')
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain('Delete Recipes?')
|
||||
expect(text(dialog())).not.toContain('Recipes!')
|
||||
await click(dialogButton('Cancel'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
expect(nameInput()!.value).toBe('Recipes!')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is a bottom sheet on mobile with 44 px controls', async () => {
|
||||
await mountAt('/schema/recipes', { width: MOBILE_WIDTH })
|
||||
await chooseDelete()
|
||||
|
||||
expect(dialog()!.className).toContain('bottom-0')
|
||||
expect(dialogButton(DELETE)!.className).toContain('h-11')
|
||||
expect(dialogButton('Cancel')!.className).toContain('h-11')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleting (in flight)', () => {
|
||||
it('sends the DELETE once, shows the loader, disables Cancel and cannot be dismissed', async () => {
|
||||
const gate = pending()
|
||||
await openConfirm({ remove: () => gate.promise })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
await click(dialogButton('Deleting'))
|
||||
|
||||
expect(deletes).toEqual(['/v1/content-type/recipes'])
|
||||
expect(dialogButton('Deleting…')!.disabled).toBe(true)
|
||||
expect(dialogButton('Cancel')!.disabled).toBe(true)
|
||||
expect(dialog()!.querySelector('button[aria-label="Close"]')).toBeNull()
|
||||
await press(dialog(), 'Escape')
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(dialog()!.contains(document.activeElement)).toBe(true)
|
||||
const focused = document.activeElement as HTMLElement
|
||||
expect(focused.tagName).toBe('SPAN')
|
||||
expect(focused.getAttribute('tabindex')).toBe('-1')
|
||||
expect(text(focused)).toBe('Delete Recipes?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleted (204)', () => {
|
||||
it('closes, leaves for /schema with no prompt, drops the type and toasts', async () => {
|
||||
const actions: string[] = []
|
||||
await mountAt('/schema/recipes', { actions })
|
||||
await typeInto(nameInput(), 'Recipes!')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
await chooseDelete()
|
||||
calls.length = 0
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
expect(text(document.body)).not.toContain('Leave without saving?')
|
||||
expect(dialog()).toBeNull()
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).toEqual([
|
||||
'events',
|
||||
'press-mentions',
|
||||
])
|
||||
expect(calls).not.toContain('/v1/content-type')
|
||||
expect(calls.filter((url) => url.startsWith('/v1/content-type/recipes'))).toEqual([])
|
||||
expect(text(document.body)).toContain('Content type deleted')
|
||||
expect(text(document.body)).toContain('Recipes was deleted.')
|
||||
expect(actions).toContain('drop')
|
||||
})
|
||||
|
||||
it('announces the renamed type after a save', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await typeInto(nameInput(), 'Recipes box')
|
||||
await click(button('Save'))
|
||||
await vi.waitFor(() => expect(chip()).not.toBe('Unsaved changes'))
|
||||
|
||||
await chooseDelete()
|
||||
expect(text(dialog())).toContain('Delete Recipes box?')
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
expect(text(document.body)).toContain('Recipes box was deleted.')
|
||||
})
|
||||
|
||||
it('shows the not found page when the address is opened again', async () => {
|
||||
await openConfirm()
|
||||
await click(dialogButton(DELETE))
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
|
||||
await router.push('/schema/recipes')
|
||||
await flushPromises()
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(text(main())).toContain('It may have been deleted, or the address may be mistyped.')
|
||||
})
|
||||
|
||||
it('deletes after a refused save and the banner goes with the page', async () => {
|
||||
await mountAt('/schema/recipes', {
|
||||
update: failWithBody(409, 'schema_incompatible'),
|
||||
entries: () => entryPage(0),
|
||||
})
|
||||
await typeInto(nameInput(), 'Recipes!')
|
||||
await click(button('Save'))
|
||||
await click(button('Back to editing'))
|
||||
expect(text(document.body)).toContain('Nothing was changed')
|
||||
|
||||
await chooseDelete()
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
expect(text(document.body)).not.toContain('Nothing was changed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Other answers of DELETE', () => {
|
||||
it('keeps the dialog on a 409 with the fallback copy and the store untouched', async () => {
|
||||
await openConfirm({ remove: failWithBody(409, 'content_type_has_entries') })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete Recipes?')
|
||||
expect(content).toContain('Recipes now has entries')
|
||||
expect(content).toContain(
|
||||
'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.',
|
||||
)
|
||||
expect(dialogButton(DELETE)).toBeUndefined()
|
||||
expect(dialogButton('Close')).toBeDefined()
|
||||
expect(dialogButton('View entries')).toBeDefined()
|
||||
expect(router.currentRoute.value.path).toBe('/schema/recipes')
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).toContain('recipes')
|
||||
})
|
||||
|
||||
it('goes to the entries from the fallback', async () => {
|
||||
await openConfirm({ remove: failWithBody(409, 'content_type_has_entries') })
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await click(dialogButton('View entries'))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/content/recipes'))
|
||||
})
|
||||
|
||||
it('reopens the confirm dialog after closing the fallback, the count is not refreshed', async () => {
|
||||
await openConfirm({ remove: failWithBody(409, 'content_type_has_entries') })
|
||||
await click(dialogButton(DELETE))
|
||||
await click(dialogButton('Close'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
calls.length = 0
|
||||
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain(CONFIRM_TEXT)
|
||||
expect(text(dialog())).not.toContain('now has entries')
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the type and shows the not found page on a 404', async () => {
|
||||
await openConfirm({ remove: failWithBody(404, 'content_type_not_found') })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).not.toContain('recipes')
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(router.currentRoute.value.path).toBe('/schema/recipes')
|
||||
})
|
||||
|
||||
it('shows the access page on a 403', async () => {
|
||||
await openConfirm({ remove: failWithBody(403, 'Only owners and admins can do this') })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
])('keeps the dialog with an alert on %s and enables both buttons', async (_, remove) => {
|
||||
await openConfirm({ remove })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain(FAILED_TITLE)
|
||||
expect(content).toContain(FAILED_TEXT)
|
||||
expect(dialogButton('Cancel')!.disabled).toBe(false)
|
||||
expect(dialogButton(DELETE)!.disabled).toBe(false)
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).toContain('recipes')
|
||||
expect(router.currentRoute.value.path).toBe('/schema/recipes')
|
||||
})
|
||||
|
||||
it('retries the same DELETE and clears the alert while it is in flight', async () => {
|
||||
let attempt = 0
|
||||
const gate = pending()
|
||||
await openConfirm({
|
||||
remove: () => {
|
||||
attempt++
|
||||
if (attempt === 1) {
|
||||
throw new Error('boom')
|
||||
}
|
||||
return gate.promise
|
||||
},
|
||||
})
|
||||
await click(dialogButton(DELETE))
|
||||
expect(text(dialog())).toContain(FAILED_TITLE)
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
expect(deletes).toEqual(['/v1/content-type/recipes', '/v1/content-type/recipes'])
|
||||
expect(text(dialog())).not.toContain(FAILED_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Offline', () => {
|
||||
function setOnline(value: boolean) {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
}
|
||||
|
||||
it('disables the destructive action offline and sends nothing on reconnect', async () => {
|
||||
await openConfirm()
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
expect(dialogButton(DELETE)!.disabled).toBe(true)
|
||||
|
||||
setOnline(true)
|
||||
await flushPromises()
|
||||
expect(dialogButton(DELETE)!.disabled).toBe(false)
|
||||
expect(deletes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Offline per stage', () => {
|
||||
function setOnline(value: boolean) {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
}
|
||||
|
||||
it('keeps View entries enabled offline in the blocked stage', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await chooseDelete()
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogButton('View entries')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps View entries enabled offline in the conflict stage', async () => {
|
||||
await openConfirm({ remove: failWithBody(409, 'content_type_has_entries') })
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogButton('View entries')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('colours the destructive action as an error and the informational one as primary', async () => {
|
||||
await openConfirm()
|
||||
expect(dialogButton(DELETE)!.className).toContain('bg-error')
|
||||
await click(dialogButton('Cancel'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
})
|
||||
|
||||
it('colours View entries as primary', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await chooseDelete()
|
||||
|
||||
expect(dialogButton('View entries')!.className).toContain('bg-primary')
|
||||
expect(dialogButton('View entries')!.className).not.toContain('bg-error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unauthenticated answers', () => {
|
||||
it('stays silent on a 401 and leaves the confirm dialog as it was', async () => {
|
||||
await openConfirm({ remove: failWith(401) })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete Recipes?')
|
||||
expect(content).not.toContain(FAILED_TITLE)
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).toContain('recipes')
|
||||
expect(dialogButton(DELETE)!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('gives the menu button an accessible name and a 44 px mobile size', async () => {
|
||||
await mountAt('/schema/recipes', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(more()!.getAttribute('aria-label')).toBe(MORE)
|
||||
expect(more()!.className).toContain('size-11')
|
||||
})
|
||||
|
||||
it('has no in-flight focus on the menu button', async () => {
|
||||
const gate = pending()
|
||||
await openConfirm({ remove: () => gate.promise })
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
expect(el('[role="alertdialog"]')!.getAttribute('aria-busy')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,7 @@ export interface SchemaMount {
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
update?: (body: PostedRequest['body'], key: string) => unknown
|
||||
entries?: (key: string) => unknown
|
||||
remove?: (key: string) => unknown
|
||||
width?: number
|
||||
content?: Responder
|
||||
builtin?: Responder
|
||||
@@ -142,6 +143,7 @@ let wrapper: VueWrapper | undefined
|
||||
export const calls: string[] = []
|
||||
export const posts: PostedRequest[] = []
|
||||
export const puts: PostedRequest[] = []
|
||||
export const deletes: string[] = []
|
||||
|
||||
export function updatedType(key: string, body: Record<string, unknown>) {
|
||||
const base = customTypes.find((type) => type.key === key)!
|
||||
@@ -152,6 +154,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
calls.length = 0
|
||||
posts.length = 0
|
||||
puts.length = 0
|
||||
deletes.length = 0
|
||||
localStorage.clear()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
@@ -173,6 +176,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'DELETE') {
|
||||
deletes.push(url)
|
||||
return (options.remove ?? (() => undefined))(url.split('/').pop()!)
|
||||
}
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body)) as PostedRequest['body']
|
||||
const key = url.split('/').pop()!
|
||||
|
||||
Reference in new issue
Block a user