feat: delete a custom type

This commit is contained in:
Florian Sylvain committed 2026-10-20 17:56:44 +02:00
1 parent 76e9e1220c
commit 5404291af2
25 files changed
+1768 -46

No files matched your search

+30 -36
View File
@@ -14,7 +14,9 @@ import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useEntryCount } from '@/composables/useEntryCount'
import { useSchemaGate } from '@/composables/useSchemaGate'
import { resolveSchemaTypeView } from '@/lib/schemaTypeView'
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
const route = useRoute()
const offlineMessage = computed(() =>
@@ -45,35 +47,23 @@ const count = useEntryCount(() =>
: null,
)
const forbidden = computed(() => listsForbidden.value || count.status.value === 'forbidden')
const countPending = computed(
() =>
customType.value !== undefined &&
(count.status.value === 'idle' || count.status.value === 'loading'),
const state = computed(() =>
resolveSchemaTypeView({
listsForbidden: listsForbidden.value,
listsLoading: listsLoading.value,
customFailed: customFailed.value,
builtinFailed: builtinFailed.value,
hasBuiltin: builtinType.value !== undefined,
hasCustom: customType.value !== undefined,
countStatus: count.status.value,
skeletonVisible: skeleton.visible.value,
}),
)
const loading = computed(() => !forbidden.value && (listsLoading.value || countPending.value))
const listsFailed = computed(() => !forbidden.value && !loading.value && customFailed.value)
const builtinListFailed = computed(
() => !forbidden.value && !loading.value && !listsFailed.value && builtinFailed.value,
)
const countFailed = computed(
() => !forbidden.value && !loading.value && count.status.value === 'failed',
)
const editable = computed(
() => !forbidden.value && !loading.value && customType.value && count.status.value === 'ready',
)
const notFound = computed(
() =>
!forbidden.value &&
!loading.value &&
!listsFailed.value &&
!builtinListFailed.value &&
!countFailed.value &&
!builtinType.value &&
!editable.value,
)
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
const bleed = computed(() => forbidden.value || notFound.value || Boolean(editable.value))
const view = computed(() => state.value.view)
const editable = computed(() => state.value.editable)
const loading = computed(() => state.value.loading)
const bleed = computed(() => state.value.bleed)
const notFound = computed(() => view.value === 'notFound')
const displayName = computed(() => customType.value?.name ?? builtinType.value?.name)
const mobileTitle = computed(() => displayName.value ?? t('Schema'))
@@ -116,6 +106,10 @@ function goHome() {
:offline-icon="editable ? 'i-lucide-wifi-off' : undefined"
:flush-offline-banner="Boolean(editable)"
>
<template #navbar-right>
<span :id="TOPBAR_ACTION_ID" />
</template>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
@@ -125,13 +119,13 @@ function goHome() {
/>
</template>
<SchemaForbidden v-if="forbidden" @back="goHome" />
<SchemaForbidden v-if="view === 'forbidden'" @back="goHome" />
<template v-else-if="sessionReady">
<SchemaTable v-if="showSkeleton" :custom="[]" :builtin="[]" loading />
<SchemaTable v-if="view === 'skeleton'" :custom="[]" :builtin="[]" loading />
<DirectoryLoadError
v-else-if="listsFailed"
v-else-if="view === 'listsFailed'"
:title="t('Couldn\'t load content types')"
:description="
t('GET /v1/content-type failed. Check your connection and try again.')
@@ -140,7 +134,7 @@ function goHome() {
/>
<DirectoryLoadError
v-else-if="builtinListFailed"
v-else-if="view === 'builtinListFailed'"
:title="t('Couldn\'t load content types')"
:description="
t('GET /v1/builtin-type failed. Check your connection and try again.')
@@ -149,15 +143,15 @@ function goHome() {
/>
<DirectoryLoadError
v-else-if="countFailed"
v-else-if="view === 'countFailed'"
:title="t('Couldn\'t load content type')"
:description="countLine"
@retry="count.retry"
/>
<SchemaBuilderPage v-else-if="editable" :key="key" :edit="edit" />
<SchemaBuilderPage v-else-if="view === 'editable'" :key="key" :edit="edit" />
<div v-else-if="builtinType" class="flex flex-col gap-4">
<div v-else-if="view === 'builtin' && builtinType" class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-lock" class="size-4.5 shrink-0 text-muted" />
<h1 class="text-xl font-semibold text-highlighted">
@@ -192,7 +186,7 @@ function goHome() {
</p>
</div>
<SchemaTypeNotFound v-else-if="notFound" />
<SchemaTypeNotFound v-else-if="view === 'notFound'" />
</template>
</DashboardShell>
</template>
@@ -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')
})
})
+7
View File
@@ -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()!