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

+1
View File
@@ -40,5 +40,6 @@ defineEmits<{ save: []; discard: [] }>()
>
{{ saving ? t('Saving…') : t('Save') }}
</UButton>
<slot />
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
defineProps<{ disabled: boolean }>()
const emit = defineEmits<{ delete: [] }>()
const trigger = ref<{ $el: HTMLElement } | null>(null)
const items = computed(() => [
[
{
label: t('Delete content type'),
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete'),
},
],
])
function focus() {
trigger.value?.$el.focus()
}
defineExpose({ focus })
</script>
<template>
<UDropdownMenu
:items="items"
:content="{ align: 'end' }"
:ui="{
content: 'w-55 p-1',
group: 'p-0',
item: 'h-8 gap-2 px-2 text-sm max-md:h-11',
itemLeadingIcon: 'size-4',
}"
>
<UButton
ref="trigger"
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:disabled="disabled"
:aria-label="t('More actions')"
class="size-8 shrink-0 justify-center text-muted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-md:size-11"
/>
</UDropdownMenu>
</template>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import { entriesCountLine } from '@/lib/schemaEntries'
import { isConfirmStage, type DeleteStage } from '@/lib/schemaDeleteStage'
const props = defineProps<{
stage: Exclude<DeleteStage, 'closed'>
name: string
keyValue: string
total: number
pending: boolean
offline: boolean
}>()
const emit = defineEmits<{ confirm: []; close: []; viewEntries: [] }>()
const blocked = computed(() => props.stage === 'blocked')
const conflict = computed(() => props.stage === 'conflict')
const confirming = computed(() => isConfirmStage(props.stage))
const informational = computed(() => !confirming.value)
const title = computed(() =>
blocked.value ? t("You can't delete {0} yet", [props.name]) : t('Delete {0}?', [props.name]),
)
const description = computed(() => {
if (blocked.value) {
return t('A content type can only be deleted when it has no entries.')
}
return t(
"{0} has no entries. Its fields and its API routes will be deleted. This can't be undone.",
[props.name],
)
})
const notice = computed(() => {
if (conflict.value) {
return {
title: t('{0} now has entries', [props.name]),
description: t(
'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.',
),
}
}
if (props.stage === 'failed') {
return {
title: t("Couldn't delete the content type"),
description: t(
'Something went wrong on our side. Nothing was changed. Try again in a moment.',
),
}
}
return null
})
function onConfirm() {
if (informational.value) {
emit('viewEntries')
} else {
emit('confirm')
}
}
</script>
<template>
<DirectoryDialog
:title="title"
:description="description"
:confirm-label="informational ? t('View entries') : t('Delete content type')"
:pending-label="t('Deleting…')"
:cancel-label="informational ? t('Close') : t('Cancel')"
:tone="informational ? 'neutral' : 'danger'"
:pending="pending"
:confirm-disabled="offline && !informational"
:notice="notice"
closable
close-gutter
@confirm="onConfirm"
@close="emit('close')"
>
<UAlert
v-if="blocked"
color="warning"
variant="soft"
icon="i-lucide-triangle-alert"
:title="entriesCountLine(name, total)"
:description="t('Delete all of its entries first, then delete the content type.')"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'text-toned',
}"
/>
<p v-else-if="confirming" class="text-toned">
{{ t('Key') }}: <span class="font-mono text-xs">{{ keyValue }}</span>
</p>
</DirectoryDialog>
</template>
+12 -4
View File
@@ -18,6 +18,7 @@ const props = withDefaults(
closable?: boolean
confirmDisabled?: boolean
wide?: boolean
closeGutter?: boolean
}>(),
{
cancelLabel: '',
@@ -27,6 +28,7 @@ const props = withDefaults(
closable: false,
confirmDisabled: false,
wide: false,
closeGutter: false,
},
)
@@ -51,6 +53,15 @@ watch(
)
const confirmText = computed(() => (props.pending ? props.pendingLabel : props.confirmLabel))
const headerClass = computed(() => {
if (props.wide && props.closable) {
return 'min-h-0 px-6 pe-20 pt-5 pb-0 sm:px-6 sm:pe-16'
}
if (props.closeGutter && props.closable) {
return 'min-h-0 px-6 pe-14 pt-5 pb-0 sm:px-6 sm:pe-10'
}
return 'min-h-0 px-6 pt-5 pb-0 sm:px-6'
})
const closeButton = computed(() =>
props.closable && !props.pending
? { class: 'size-7 justify-center text-muted max-md:size-11' }
@@ -86,10 +97,7 @@ function onCancel() {
}"
:ui="{
content: contentClass,
header:
props.wide && props.closable
? 'min-h-0 px-6 pe-20 pt-5 pb-0 sm:px-6 sm:pe-16'
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
header: headerClass,
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
+46 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import ApiPreview from '@/components/ApiPreview.vue'
@@ -9,7 +9,9 @@ import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue'
import BuilderActions from '@/components/BuilderActions.vue'
import BuilderBadge from '@/components/BuilderBadge.vue'
import BuilderInspector from '@/components/BuilderInspector.vue'
import BuilderMoreMenu from '@/components/BuilderMoreMenu.vue'
import BuilderTypeBar from '@/components/BuilderTypeBar.vue'
import DeleteTypeDialog from '@/components/DeleteTypeDialog.vue'
import FieldList from '@/components/FieldList.vue'
import FieldSheet from '@/components/FieldSheet.vue'
import RetryAlert from '@/components/RetryAlert.vue'
@@ -17,10 +19,12 @@ import SchemaForbidden from '@/components/SchemaForbidden.vue'
import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
import { useSchemaDelete } from '@/composables/useSchemaDelete'
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
import { HOME_PATH } from '@/lib/routePaths'
import { sampleJson } from '@/lib/schemaBuilderSample'
import { entriesNotice } from '@/lib/schemaEntries'
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
import { formatClock } from '@/lib/updateFormat'
const DESKTOP_QUERY = '(min-width: 48rem)'
@@ -31,9 +35,15 @@ const router = useRouter()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const builder = useSchemaBuilder(props.edit)
const { form } = builder
const remove = props.edit ? useSchemaDelete(() => props.edit!) : undefined
const moreMenu = ref<InstanceType<typeof BuilderMoreMenu> | null>(null)
const moreDisabled = computed(() => builder.saving.value || Boolean(remove?.pending.value))
const forbidden = computed(
() => builder.failure.value === 'forbidden' || Boolean(remove?.forbidden.value),
)
useUnsavedChangesGuard({
isDirty: () => builder.dirty.value,
isDirty: () => builder.dirty.value && !remove?.done.value,
title: () => form.value.name.trim() || t('New content type'),
})
@@ -63,6 +73,16 @@ const selectedName = computed(() => builder.selected.value?.label.trim() || t('U
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
const reviewName = computed(() => builder.baseline.value?.name ?? '')
watch(
() => remove?.stage.value,
async (stage) => {
if (stage === 'closed') {
await nextTick()
moreMenu.value?.focus()
}
},
)
function goHome() {
router.push(HOME_PATH)
}
@@ -106,7 +126,7 @@ async function restoreRowFocus() {
</script>
<template>
<SchemaForbidden v-if="builder.failure.value === 'forbidden'" @back="goHome" />
<SchemaForbidden v-if="forbidden" @back="goHome" />
<template v-else>
<div
@@ -139,7 +159,14 @@ async function restoreRowFocus() {
:saved-at="savedClock"
@save="builder.save"
@discard="builder.discard"
/>
>
<BuilderMoreMenu
v-if="remove"
ref="moreMenu"
:disabled="moreDisabled"
@delete="remove.open"
/>
</BuilderActions>
</template>
</BuilderTypeBar>
</div>
@@ -284,6 +311,9 @@ async function restoreRowFocus() {
</div>
<template v-if="!isDesktop">
<Teleport v-if="remove" :to="`#${TOPBAR_ACTION_ID}`" defer>
<BuilderMoreMenu ref="moreMenu" :disabled="moreDisabled" @delete="remove.open" />
</Teleport>
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
<FieldSheet
v-model:open="fieldSheetOpen"
@@ -316,6 +346,18 @@ async function restoreRowFocus() {
@apply="builder.apply"
@close="builder.closeReview"
/>
<DeleteTypeDialog
v-if="remove && remove.stage.value !== 'closed'"
:stage="remove.stage.value"
:name="remove.type.value.name"
:key-value="remove.type.value.key"
:total="remove.total.value"
:pending="remove.pending.value"
:offline="remove.offline.value"
@confirm="remove.confirm"
@close="remove.close"
@view-entries="remove.viewEntries"
/>
<SchemaRefusalDialog
v-if="builder.refusal.value === 'dialog'"
:causes="refusalCauses"
@@ -205,6 +205,43 @@ describe('updateContentType', () => {
})
})
describe('deleteContentType', () => {
it('DELETEs the encoded key and resolves on 204', async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 204 }))
await expect(useContentTypes().deleteContentType('a/b')).resolves.toBeUndefined()
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('/v1/content-type/a%2Fb')
expect(init.method).toBe('DELETE')
})
it.each([
[404, 'content_type_not_found'],
[409, 'content_type_has_entries'],
[403, 'Only owners and admins can do this'],
[500, 'boom'],
])('rejects %i with its plain-text body', async (status, text) => {
answer(status, text)
const failure = await useContentTypes()
.deleteContentType('recipes')
.catch((error) => error)
expect(failure).toBeInstanceOf(ApiError)
expect(failure.status).toBe(status)
expect(failure.body).toBe(text)
})
it('lets a network failure through', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await expect(useContentTypes().deleteContentType('recipes')).rejects.toBeInstanceOf(
NetworkError,
)
})
})
describe('countEntries', () => {
it('reads the total of a five-entry page', async () => {
answer(200, { items: [], total: 37, page: 1, size: 5 })
@@ -0,0 +1,221 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, shallowRef } from 'vue'
import router from '@/router'
import { useSchemaDelete } from '@/composables/useSchemaDelete'
import type { EditSource } from '@/composables/useSchemaBuilder'
import { useContentTypesStore } from '@/stores/contentTypes'
const recipes = {
id: 2,
key: 'recipes',
name: 'Recipes',
icon: null,
fields: [],
created_at: '2026-09-01T10:00:00Z',
updated_at: '2026-10-02T10:00:00Z',
}
const fetchMock = vi.fn()
const source = shallowRef<EditSource>({ type: recipes, total: 0 })
let wrapper: VueWrapper | undefined
function deleteCalls() {
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'DELETE')
}
function setup() {
let api!: ReturnType<typeof useSchemaDelete>
wrapper = mount(
defineComponent({
setup() {
api = useSchemaDelete(() => source.value)
return () => null
},
}),
{ global: { plugins: [router] } },
)
return api
}
beforeEach(() => {
setActivePinia(createPinia())
source.value = { type: recipes, total: 0 }
fetchMock.mockReset()
fetchMock.mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('useSchemaDelete', () => {
it('opens the blocked stage with entries and the confirm stage without', () => {
const api = setup()
api.open()
expect(api.stage.value).toBe('confirm')
source.value = { type: recipes, total: 3 }
api.open()
expect(api.stage.value).toBe('blocked')
})
it('reads the source lazily so a renamed type is the one it acts on', async () => {
const api = setup()
api.open()
source.value = { type: { ...recipes, name: 'Recipes box' }, total: 0 }
expect(api.type.value.name).toBe('Recipes box')
})
it('keeps the leave guard off after a successful delete', async () => {
vi.spyOn(router, 'push').mockResolvedValue(undefined)
const api = setup()
api.open()
await api.confirm()
expect(api.done.value).toBe(true)
})
it('puts the leave guard back when the navigation is rejected', async () => {
vi.spyOn(router, 'push').mockRejectedValue(new Error('boom'))
const api = setup()
api.open()
await api.confirm()
await flushPromises()
expect(api.done.value).toBe(false)
expect(api.stage.value).toBe('closed')
})
it('puts the leave guard back when the navigation fails', async () => {
vi.spyOn(router, 'push').mockResolvedValue({ type: 4 } as never)
const api = setup()
api.open()
await api.confirm()
expect(api.done.value).toBe(false)
})
it('still drops the deleted type from the store when the navigation fails', async () => {
vi.spyOn(router, 'push').mockRejectedValue(new Error('boom'))
const store = useContentTypesStore()
const drop = vi.spyOn(store, 'drop')
const api = setup()
api.open()
await api.confirm()
expect(drop).toHaveBeenCalledWith('recipes')
})
it('goes to the failed stage on a server error and back to confirm on retry', async () => {
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 500 }))
vi.spyOn(router, 'push').mockResolvedValue(undefined)
const api = setup()
api.open()
await api.confirm()
expect(api.stage.value).toBe('failed')
const retry = api.confirm()
expect(api.stage.value).toBe('confirm')
await retry
expect(api.done.value).toBe(true)
})
it('sends a single DELETE when confirm is called again while one is in flight', async () => {
let release!: (response: Response) => void
fetchMock.mockReturnValueOnce(new Promise<Response>((done) => (release = done)))
vi.spyOn(router, 'push').mockResolvedValue(undefined)
const api = setup()
api.open()
const first = api.confirm()
await api.confirm()
release(new Response(null, { status: 204 }))
await first
expect(deleteCalls()).toHaveLength(1)
})
it('sends nothing when confirm is called offline', async () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
const api = setup()
api.open()
window.dispatchEvent(new Event('offline'))
await flushPromises()
await api.confirm()
expect(api.offline.value).toBe(true)
expect(deleteCalls()).toHaveLength(0)
expect(api.stage.value).toBe('confirm')
})
it('does not close while a delete is in flight', async () => {
let release!: (response: Response) => void
fetchMock.mockReturnValueOnce(new Promise<Response>((done) => (release = done)))
vi.spyOn(router, 'push').mockResolvedValue(undefined)
const api = setup()
api.open()
const running = api.confirm()
api.close()
expect(api.stage.value).toBe('confirm')
expect(api.pending.value).toBe(true)
release(new Response(null, { status: 204 }))
await running
})
it('closes when nothing is in flight', () => {
const api = setup()
api.open()
api.close()
expect(api.stage.value).toBe('closed')
})
it('goes to the conflict stage on a 409 about entries', async () => {
fetchMock.mockResolvedValueOnce(new Response('content_type_has_entries', { status: 409 }))
const api = setup()
api.open()
await api.confirm()
expect(api.stage.value).toBe('conflict')
})
it('treats a 409 with any other body as a plain failure', async () => {
fetchMock.mockResolvedValueOnce(new Response('something_else', { status: 409 }))
const api = setup()
api.open()
await api.confirm()
expect(api.stage.value).toBe('failed')
})
it('stays silent on a 401 and leaves the stage as it was', async () => {
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 401 }))
const api = setup()
api.open()
await api.confirm()
expect(api.stage.value).toBe('confirm')
expect(api.pending.value).toBe(false)
expect(api.done.value).toBe(false)
})
})
+8
View File
@@ -64,6 +64,13 @@ export function useContentTypes() {
})
}
async function deleteContentType(key: string): Promise<void> {
await request<void>(`/v1/content-type/${encodeURIComponent(key)}`, {
method: 'DELETE',
...options,
})
}
async function countEntries(key: string): Promise<number> {
const page = await request<{ total?: unknown }>(
`/v1/content-type/${encodeURIComponent(key)}/entry?page=1&size=5`,
@@ -80,6 +87,7 @@ export function useContentTypes() {
listBuiltinTypes,
createContentType,
updateContentType,
deleteContentType,
countEntries,
}
}
+123
View File
@@ -0,0 +1,123 @@
import { computed, ref } from 'vue'
import { useOnline } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
import type { EditSource } from '@/composables/useSchemaBuilder'
import { contentLink } from '@/lib/contentNav'
import { isConfirmStage, type DeleteStage } from '@/lib/schemaDeleteStage'
import { SCHEMA_PATH } from '@/lib/routePaths'
import { useContentTypesStore } from '@/stores/contentTypes'
const HAS_ENTRIES_BODY = 'content_type_has_entries'
const TOAST_ID = 'content-type-deleted'
export function useSchemaDelete(source: () => EditSource) {
const router = useRouter()
const store = useContentTypesStore()
const toast = useLocalizedToast()
const online = useOnline()
const stage = ref<DeleteStage>('closed')
const pending = ref(false)
const forbidden = ref(false)
const done = ref(false)
const offline = computed(() => !online.value)
const type = computed(() => source().type)
const total = computed(() => source().total)
function open() {
if (pending.value) {
return
}
stage.value = total.value > 0 ? 'blocked' : 'confirm'
}
function close() {
if (!pending.value) {
stage.value = 'closed'
}
}
async function viewEntries() {
stage.value = 'closed'
await router.push(contentLink(type.value.key)).catch(() => undefined)
}
function announceDeleted(name: string) {
toast.add(() => ({
id: TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Content type deleted'),
description: t('{0} was deleted.', [name]),
}))
}
async function leave(deleted: ContentType) {
done.value = true
stage.value = 'closed'
announceDeleted(deleted.name)
const arrived = await router.push(SCHEMA_PATH).then(
(failure) => !failure,
() => false,
)
if (!arrived) {
done.value = false
}
store.drop(deleted.key)
}
function refuse(error: unknown, key: string) {
const status = error instanceof ApiError ? error.status : 0
const body = error instanceof ApiError ? error.body : undefined
if (status === 401) {
return
}
if (status === 404) {
stage.value = 'closed'
store.drop(key)
} else if (status === 403) {
stage.value = 'closed'
forbidden.value = true
} else if (status === 409 && body === HAS_ENTRIES_BODY) {
stage.value = 'conflict'
} else {
stage.value = 'failed'
}
}
async function confirm() {
if (pending.value || offline.value || !isConfirmStage(stage.value)) {
return
}
const deleted = type.value
pending.value = true
stage.value = 'confirm'
try {
await useContentTypes().deleteContentType(deleted.key)
} catch (error) {
pending.value = false
refuse(error, deleted.key)
return
}
pending.value = false
await leave(deleted)
}
return {
stage,
pending,
forbidden,
done,
offline,
type,
total,
open,
close,
viewEntries,
confirm,
}
}
+4
View File
@@ -9,6 +9,8 @@ const SOURCES = [
'components/AuthorFormPanel.vue',
'components/AuthorViewPanel.vue',
'components/DashboardShell.vue',
'components/BuilderMoreMenu.vue',
'components/DeleteTypeDialog.vue',
'components/DirectoryBadge.vue',
'components/DirectoryDialog.vue',
'components/DirectoryField.vue',
@@ -26,10 +28,12 @@ const SOURCES = [
'components/SocialLinksRepeater.vue',
'components/UserSelect.vue',
'composables/useDirectoryToasts.ts',
'composables/useSchemaDelete.ts',
'composables/useDirectoryView.ts',
'lib/directory.ts',
'lib/directoryCopy.ts',
'lib/directoryRules.ts',
'lib/schemaEntries.ts',
'lib/siteTitleTemplate.ts',
'views/AuthorsView.vue',
'views/SitesView.vue',
+17
View File
@@ -2486,6 +2486,23 @@ export const messages = {
'Values must now be unique.': 'Les valeurs doivent maintenant être uniques.',
'Values no longer need to be unique.': 'Les valeurs n’ont plus besoin d’être uniques.',
'Your content type is as it was': 'Votre type de contenu est tel qu’il était',
'Delete content type': 'Supprimer le type de contenu',
"You can't delete {0} yet": 'Vous ne pouvez pas encore supprimer {0}',
'A content type can only be deleted when it has no entries.':
'Un type de contenu ne peut être supprimé que s’il n’a aucune entrée.',
'Delete all of its entries first, then delete the content type.':
'Supprimez d’abord toutes ses entrées, puis supprimez le type de contenu.',
"{0} has no entries. Its fields and its API routes will be deleted. This can't be undone.":
'{0} n’a aucune entrée. Ses champs et ses routes d’API seront supprimés. Cette action est irréversible.',
'{0} has 1 entry': '{0} a 1 entrée',
'{0} has {1} entries': '{0} a {1} entrées',
'{0} now has entries': '{0} a maintenant des entrées',
'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.':
'Quelqu’un a ajouté une entrée après le chargement de cette page, donc rien n’a été supprimé. Supprimez d’abord les entrées, puis réessayez.',
"Couldn't delete the content type": 'Impossible de supprimer le type de contenu',
'View entries': 'Voir les entrées',
'Content type deleted': 'Type de contenu supprimé',
'{0} was deleted.': '{0} a été supprimé.',
} as const
export type MessageKey = keyof typeof messages
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import { resolveSchemaTypeView, type SchemaTypeInputs } from '@/lib/schemaTypeView'
const SETTLED: SchemaTypeInputs = {
listsForbidden: false,
listsLoading: false,
customFailed: false,
builtinFailed: false,
hasBuiltin: false,
hasCustom: false,
skeletonVisible: false,
countStatus: 'idle',
}
const resolve = (patch: Partial<SchemaTypeInputs>) =>
resolveSchemaTypeView({ ...SETTLED, ...patch })
describe('resolveSchemaTypeView', () => {
it.each([
['forbidden lists', { listsForbidden: true }],
['forbidden count', { countStatus: 'forbidden' }],
[
'forbidden over loading',
{ listsForbidden: true, listsLoading: true, skeletonVisible: true },
],
['forbidden over a custom type', { countStatus: 'forbidden', hasCustom: true }],
] as const)('is forbidden and bleeds for %s', (_, patch) => {
expect(resolve(patch)).toEqual({
view: 'forbidden',
editable: false,
loading: false,
bleed: true,
})
})
it.each([
['lists loading', { listsLoading: true }],
['count idle on a custom type', { hasCustom: true, countStatus: 'idle' }],
['count loading on a custom type', { hasCustom: true, countStatus: 'loading' }],
] as const)('shows the skeleton only once visible, %s', (_, patch) => {
expect(resolve({ ...patch, skeletonVisible: true })).toEqual({
view: 'skeleton',
editable: false,
loading: true,
bleed: false,
})
expect(resolve({ ...patch, skeletonVisible: false })).toEqual({
view: 'blank',
editable: false,
loading: true,
bleed: false,
})
})
it('does not wait for the count when there is no custom type', () => {
expect(resolve({ hasBuiltin: true, countStatus: 'loading' }).loading).toBe(false)
})
it('reports a failed custom list before anything else', () => {
expect(
resolve({
customFailed: true,
builtinFailed: true,
hasBuiltin: true,
hasCustom: true,
countStatus: 'failed',
}),
).toEqual({ view: 'listsFailed', editable: false, loading: false, bleed: false })
})
it('reports a failed built-in list after the custom one', () => {
expect(resolve({ builtinFailed: true, hasBuiltin: true, countStatus: 'failed' })).toEqual({
view: 'builtinListFailed',
editable: false,
loading: false,
bleed: false,
})
})
it('reports a failed count on a custom type', () => {
expect(resolve({ hasCustom: true, hasBuiltin: true, countStatus: 'failed' })).toEqual({
view: 'countFailed',
editable: false,
loading: false,
bleed: false,
})
})
it('is editable and bleeds for a custom type with a ready count', () => {
expect(resolve({ hasCustom: true, hasBuiltin: true, countStatus: 'ready' })).toEqual({
view: 'editable',
editable: true,
loading: false,
bleed: true,
})
})
it.each(['idle', 'ready', 'missing'] as const)(
'shows a built-in type without bleeding, count %s',
(countStatus) => {
expect(resolve({ hasBuiltin: true, countStatus })).toEqual({
view: 'builtin',
editable: false,
loading: false,
bleed: false,
})
},
)
it.each(['idle', 'missing', 'ready'] as const)(
'is not found and bleeds with no type at all, count %s',
(countStatus) => {
expect(resolve({ countStatus })).toEqual({
view: 'notFound',
editable: false,
loading: false,
bleed: true,
})
},
)
it('is not found for a custom type whose count went missing', () => {
expect(resolve({ hasCustom: true, countStatus: 'missing' }).view).toBe('notFound')
})
})
+5
View File
@@ -0,0 +1,5 @@
export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict'
export function isConfirmStage(stage: DeleteStage) {
return stage === 'confirm' || stage === 'failed'
}
+6
View File
@@ -19,3 +19,9 @@ export function entriesLine(name: string, total: number) {
formatNumber(total),
])
}
export function entriesCountLine(name: string, total: number) {
return total === 1
? t('{0} has 1 entry', [name])
: t('{0} has {1} entries', [name, formatNumber(total)])
}
+88
View File
@@ -0,0 +1,88 @@
import type { EntryCountStatus } from '@/composables/useEntryCount'
export type SchemaTypeViewName =
| 'forbidden'
| 'skeleton'
| 'listsFailed'
| 'builtinListFailed'
| 'countFailed'
| 'editable'
| 'builtin'
| 'notFound'
| 'blank'
export interface SchemaTypeInputs {
listsForbidden: boolean
listsLoading: boolean
customFailed: boolean
builtinFailed: boolean
hasBuiltin: boolean
hasCustom: boolean
countStatus: EntryCountStatus
skeletonVisible: boolean
}
export interface SchemaTypeState {
view: SchemaTypeViewName
editable: boolean
loading: boolean
bleed: boolean
}
export function resolveSchemaTypeView(input: SchemaTypeInputs): SchemaTypeState {
const forbidden = input.listsForbidden || input.countStatus === 'forbidden'
const countPending =
input.hasCustom && (input.countStatus === 'idle' || input.countStatus === 'loading')
const loading = !forbidden && (input.listsLoading || countPending)
const settled = !forbidden && !loading
const editable = settled && input.hasCustom && input.countStatus === 'ready'
const listsFailed = settled && input.customFailed
const builtinListFailed = settled && !listsFailed && input.builtinFailed
const countFailed = settled && input.countStatus === 'failed'
const notFound =
settled &&
!listsFailed &&
!builtinListFailed &&
!countFailed &&
!input.hasBuiltin &&
!editable
return {
view: pickView({
forbidden,
skeleton: loading && input.skeletonVisible,
listsFailed,
builtinListFailed,
countFailed,
editable,
builtin: input.hasBuiltin,
notFound,
}),
editable,
loading,
bleed: forbidden || notFound || editable,
}
}
interface ViewFlags {
forbidden: boolean
skeleton: boolean
listsFailed: boolean
builtinListFailed: boolean
countFailed: boolean
editable: boolean
builtin: boolean
notFound: boolean
}
function pickView(flags: ViewFlags): SchemaTypeViewName {
if (flags.forbidden) return 'forbidden'
if (flags.skeleton) return 'skeleton'
if (flags.listsFailed) return 'listsFailed'
if (flags.builtinListFailed) return 'builtinListFailed'
if (flags.countFailed) return 'countFailed'
if (flags.editable) return 'editable'
if (flags.builtin) return 'builtin'
if (flags.notFound) return 'notFound'
return 'blank'
}
+1
View File
@@ -0,0 +1 @@
export const TOPBAR_ACTION_ID = 'topbar-action'
@@ -360,6 +360,31 @@ describe('content types store: replace, drop and reset', () => {
expect(store.items.map((item) => item.key)).toEqual(['events', 'press-mentions'])
})
it('keeps a type dropped while the list is still loading out of the in-flight list', async () => {
const first = deferred()
listContentTypes.mockReturnValueOnce(first.promise).mockResolvedValueOnce([events])
const store = useContentTypesStore()
const loaded = store.load()
store.drop('recipes')
first.resolve([events, recipes])
await loaded
expect(listContentTypes).toHaveBeenCalledTimes(2)
expect(store.items.map((item) => item.key)).toEqual(['events'])
})
it('does nothing when dropping before any load started', async () => {
listContentTypes.mockResolvedValue([events, recipes])
const store = useContentTypesStore()
store.drop('recipes')
await store.load()
expect(listContentTypes).toHaveBeenCalledTimes(1)
expect(store.items.map((item) => item.key)).toEqual(['events', 'recipes'])
})
it('reset clears both lists so the next session loads them again', async () => {
const store = await loadedStore()
+4
View File
@@ -112,6 +112,10 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
}
function drop(matches: (existing: T) => boolean) {
if (status.value === 'loading') {
stale = true
return
}
if (status.value !== 'ready') {
return
}
+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()!