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
@@ -40,5 +40,6 @@ defineEmits<{ save: []; discard: [] }>()
|
||||
>
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
</UButton>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict'
|
||||
|
||||
export function isConfirmStage(stage: DeleteStage) {
|
||||
return stage === 'confirm' || stage === 'failed'
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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