feat: edit an existing custom type

This commit is contained in:
Florian Sylvain committed 2026-10-20 04:42:50 +02:00
1 parent 083cc9e23e
commit 8e344b2500
57 files changed
+5648 -425

No files matched your search

+9 -245
View File
@@ -1,102 +1,29 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import ApiPreview from '@/components/ApiPreview.vue'
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 BuilderTypeBar from '@/components/BuilderTypeBar.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import FieldList from '@/components/FieldList.vue'
import FieldSheet from '@/components/FieldSheet.vue'
import RetryAlert from '@/components/RetryAlert.vue'
import SchemaBuilderPage from '@/components/SchemaBuilderPage.vue'
import SchemaForbidden from '@/components/SchemaForbidden.vue'
import { useSchemaBuilder } from '@/composables/useSchemaBuilder'
import { useSchemaGate } from '@/composables/useSchemaGate'
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
import { sampleJson } from '@/lib/schemaBuilderSample'
const DESKTOP_QUERY = '(min-width: 48rem)'
const OFFLINE_COPY = "You're offline. Changes will be saved when you reconnect."
const offlineMessage = computed(() =>
t("You're offline. Changes will be saved when you reconnect."),
)
const router = useRouter()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { sessionReady, forbidden } = useSchemaGate('custom')
const builder = useSchemaBuilder()
const { form } = builder
useUnsavedChangesGuard({
isDirty: () => builder.dirty.value,
title: () => form.value.name.trim() || t('New content type'),
})
const apiOpen = ref(false)
const apiSheetOpen = ref(false)
const fieldSheetOpen = ref(false)
const showForbidden = computed(() => forbidden.value || builder.failure.value === 'forbidden')
const mobileTitle = computed(() => t('New content type'))
const breadcrumb = computed(() => [
{ label: t('Schema'), to: SCHEMA_PATH },
{ label: t('New content type') },
])
const json = computed(() => sampleJson(form.value.key, form.value.fields))
const selectedIndex = computed(() =>
form.value.fields.findIndex((field) => field.uid === builder.selectedUid.value),
)
const selectedErrors = computed(() =>
builder.selected.value
? (builder.shown.value.fields.get(builder.selected.value.uid) ?? {})
: {},
)
const selectedName = computed(() => builder.selected.value?.label.trim() || t('Untitled field'))
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
function goHome() {
router.push(HOME_PATH)
}
function onSelect(uid: number) {
builder.selectField(uid)
if (!isDesktop.value) {
fieldSheetOpen.value = true
}
}
function onAdd() {
builder.addField()
if (!isDesktop.value) {
fieldSheetOpen.value = true
}
}
function onRemoveSelected() {
const uid = builder.selectedUid.value
if (uid !== null) {
builder.removeField(uid)
}
if (!isDesktop.value) {
fieldSheetOpen.value = false
}
}
function onMoveSelected(delta: number) {
const uid = builder.selectedUid.value
if (uid !== null) {
builder.moveField(uid, delta)
}
}
async function restoreRowFocus() {
await nextTick()
const uid = builder.selectedUid.value
document.querySelector<HTMLElement>(`[data-field-row][data-uid="${uid}"]`)?.focus()
}
</script>
<template>
@@ -105,8 +32,9 @@ async function restoreRowFocus() {
:mobile-title="mobileTitle"
:back-to="SCHEMA_PATH"
:back-label="t('Schema')"
:offline-message="t(OFFLINE_COPY)"
:offline-message="offlineMessage"
offline-icon="i-lucide-wifi-off"
flush-offline-banner
>
<template #breadcrumb>
<UBreadcrumb
@@ -117,171 +45,7 @@ async function restoreRowFocus() {
/>
</template>
<SchemaForbidden v-if="showForbidden" @back="goHome" />
<div
v-else-if="sessionReady"
class="flex min-h-0 flex-1 flex-col"
data-testid="schema-builder"
>
<div class="shrink-0 border-b border-default bg-default p-4 md:px-6">
<BuilderTypeBar
:name="form.name"
:key-value="form.key"
:key-mode="form.keyMode"
:name-error="builder.shown.value.type.name"
:key-error="builder.shown.value.type.key"
:disabled="builder.saving.value"
:key-focus-token="builder.keyFocusToken.value"
@update:name="builder.setName"
@update:key="builder.setKey"
@unlock="builder.unlockKey"
@reset="builder.resetKey"
>
<template v-if="isDesktop" #actions>
<BuilderActions
:status="builder.status.value"
:problems="builder.problems.value"
:can-save="builder.canSave.value"
:can-reveal="builder.canReveal.value"
:can-discard="builder.canDiscard.value"
:saving="builder.saving.value"
@save="builder.save"
@discard="builder.discard"
/>
</template>
</BuilderTypeBar>
</div>
<div
v-if="builder.failure.value === 'failed' || builder.failure.value === 'invalid'"
class="px-4 pt-3 md:px-6"
>
<RetryAlert
v-if="builder.failure.value === 'failed'"
:title="t('Couldn\'t save the content type')"
:description="
t(
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
)
"
@retry="builder.save"
/>
<UAlert
v-else
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Check the fields marked in the list.')"
/>
</div>
<div class="flex min-h-0 flex-1 flex-col md:flex-row">
<div
class="flex flex-col gap-2 p-4 md:w-95 md:shrink-0 md:border-r md:border-default md:bg-muted"
>
<FieldList
:fields="form.fields"
:selected-uid="builder.selectedUid.value"
:errors="builder.shown.value.fields"
:list-error="builder.shown.value.type.fields"
:disabled="builder.saving.value"
:mobile="!isDesktop"
@select="onSelect"
@add="onAdd"
@remove="builder.removeField"
@move="builder.moveField"
@reorder="builder.reorderFields"
>
<button
v-if="!isDesktop"
type="button"
data-testid="api-row"
class="flex min-h-11 items-center gap-2.5 border border-default bg-default px-3 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
@click="apiSheetOpen = true"
>
<UIcon name="i-lucide-braces" class="size-4" />
<span class="flex-1 text-left font-medium">{{ t('API preview') }}</span>
<UIcon name="i-lucide-chevron-right" class="size-3.5 text-muted" />
</button>
</FieldList>
</div>
<div
v-if="isDesktop"
class="flex min-w-0 flex-1 flex-col gap-3.5 bg-default px-6 py-4"
data-testid="inspector-pane"
>
<template v-if="builder.selected.value">
<div class="flex items-center gap-2">
<h2 class="text-md font-semibold text-highlighted">
{{ sheetTitle }}
</h2>
<BuilderBadge kind="new" />
</div>
<BuilderInspector
:builder="builder"
:field="builder.selected.value"
:errors="selectedErrors"
:compact="false"
/>
</template>
<template v-else>
<h2 class="text-md font-semibold text-highlighted">
{{ t('Field settings') }}
</h2>
<p class="text-muted">
{{ t('Add a field, then choose its type and rules here.') }}
</p>
</template>
</div>
</div>
<ApiPreview
v-if="isDesktop"
v-model:open="apiOpen"
:key-value="form.key"
:json="json"
/>
<div
v-else
class="sticky bottom-0 mt-auto border-t border-default bg-default px-4 py-3"
>
<BuilderActions
:status="builder.status.value"
:problems="builder.problems.value"
:can-save="builder.canSave.value"
:can-reveal="builder.canReveal.value"
:can-discard="builder.canDiscard.value"
:saving="builder.saving.value"
@save="builder.save"
@discard="builder.discard"
/>
</div>
</div>
<template v-if="!isDesktop && sessionReady && !showForbidden">
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
<FieldSheet
v-model:open="fieldSheetOpen"
:title="sheetTitle"
:first="selectedIndex <= 0"
:last="selectedIndex === form.fields.length - 1"
:disabled="builder.saving.value"
@up="onMoveSelected(-1)"
@down="onMoveSelected(1)"
@remove="onRemoveSelected"
@closed="restoreRowFocus"
>
<BuilderInspector
v-if="builder.selected.value"
:builder="builder"
:field="builder.selected.value"
:errors="selectedErrors"
compact
/>
</FieldSheet>
</template>
<SchemaForbidden v-if="forbidden" @back="goHome" />
<SchemaBuilderPage v-else-if="sessionReady" />
</DashboardShell>
</template>
+113 -16
View File
@@ -1,30 +1,104 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import SchemaBuilderPage from '@/components/SchemaBuilderPage.vue'
import SchemaFieldTable from '@/components/SchemaFieldTable.vue'
import SchemaForbidden from '@/components/SchemaForbidden.vue'
import SchemaTable from '@/components/SchemaTable.vue'
import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useEntryCount } from '@/composables/useEntryCount'
import { useSchemaGate } from '@/composables/useSchemaGate'
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
const route = useRoute()
const offlineMessage = computed(() =>
t("You're offline. Changes will be saved when you reconnect."),
)
const router = useRouter()
const skeleton = useDelayedSkeleton()
const { sessionReady, forbidden, loading, showSkeleton, builtinFailed, builtinItems, retry } =
useSchemaGate('builtin')
const failed = computed(() => !forbidden.value && builtinFailed.value)
const type = computed(() => builtinItems.value.find((item) => item.key === route.params.key))
const mobileTitle = computed(() => type.value?.name ?? t('Schema'))
const {
sessionReady,
forbidden: listsForbidden,
loading: listsLoading,
customFailed,
builtinFailed,
customItems,
builtinItems,
retry: retryLists,
} = useSchemaGate('all')
const key = computed(() => String(route.params.key))
const builtinType = computed(() => builtinItems.value.find((item) => item.key === key.value))
const customType = computed(() =>
builtinType.value ? undefined : customItems.value.find((item) => item.key === key.value),
)
const count = useEntryCount(() =>
customType.value && sessionReady.value && !listsForbidden.value && !listsLoading.value
? customType.value.key
: 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 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 displayName = computed(() => customType.value?.name ?? builtinType.value?.name)
const mobileTitle = computed(() => displayName.value ?? t('Schema'))
const breadcrumb = computed(() => [
{ label: t('Schema'), to: SCHEMA_PATH },
...(type.value ? [{ label: type.value.name }] : []),
...(displayName.value && !notFound.value ? [{ label: displayName.value }] : []),
])
const edit = computed(() =>
customType.value ? { type: customType.value, total: count.total.value } : undefined,
)
const countLine = computed(() =>
t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [key.value]),
)
watch(
loading,
(value) => {
if (value) {
skeleton.start()
} else {
skeleton.stop()
}
},
{ immediate: true },
)
function goHome() {
router.push(HOME_PATH)
@@ -33,11 +107,14 @@ function goHome() {
<template>
<DashboardShell
compact-padding
:full-bleed="forbidden || (sessionReady && !loading && !failed && !type)"
:compact-padding="!editable"
:full-bleed="bleed"
:mobile-title="mobileTitle"
:back-to="SCHEMA_PATH"
:back-label="t('Schema')"
:offline-message="editable ? offlineMessage : undefined"
:offline-icon="editable ? 'i-lucide-wifi-off' : undefined"
:flush-offline-banner="Boolean(editable)"
>
<template #breadcrumb>
<UBreadcrumb
@@ -54,18 +131,38 @@ function goHome() {
<SchemaTable v-if="showSkeleton" :custom="[]" :builtin="[]" loading />
<DirectoryLoadError
v-else-if="failed"
v-else-if="listsFailed"
:title="t('Couldn\'t load content types')"
:description="
t('GET /v1/content-type failed. Check your connection and try again.')
"
@retry="retryLists"
/>
<DirectoryLoadError
v-else-if="builtinListFailed"
:title="t('Couldn\'t load content types')"
:description="
t('GET /v1/builtin-type failed. Check your connection and try again.')
"
@retry="retry"
@retry="retryLists"
/>
<div v-else-if="type" class="flex flex-col gap-4">
<DirectoryLoadError
v-else-if="countFailed"
:title="t('Couldn\'t load content type')"
:description="countLine"
@retry="count.retry"
/>
<SchemaBuilderPage v-else-if="editable" :key="key" :edit="edit" />
<div v-else-if="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">{{ type.name }}</h1>
<h1 class="text-xl font-semibold text-highlighted">
{{ builtinType.name }}
</h1>
<DirectoryBadge kind="builtin" />
</div>
<UAlert
@@ -76,7 +173,7 @@ function goHome() {
:description="
t(
'{0} is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields.',
[type.name],
[builtinType.name],
)
"
:ui="{
@@ -85,7 +182,7 @@ function goHome() {
description: 'text-toned',
}"
/>
<SchemaFieldTable :fields="type.fields" />
<SchemaFieldTable :fields="builtinType.fields" />
<p class="text-xs/5 text-muted">
{{
t(
@@ -95,7 +192,7 @@ function goHome() {
</p>
</div>
<SchemaTypeNotFound v-else-if="!loading" />
<SchemaTypeNotFound v-else-if="notFound" />
</template>
</DashboardShell>
</template>
@@ -0,0 +1,514 @@
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 { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { button, mountAt, puts, text, unmountAll } from './schemaTestKit'
import {
addChoice,
addField,
all,
badgesOf,
byLabel,
chip,
chooseDefault,
click,
defaultInput,
discardButton,
el,
fieldKeyInput,
inspector,
inspectorTitle,
keyInput,
labelInput,
limit,
live,
makeUnsavedChanges,
nameInput,
notice,
press,
removeSelected,
rowLabels,
rows,
saveButton,
selectRow,
setMax,
toggle,
typeInto,
undoButtons,
undoRows,
} from './builderKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
beforeEach(() => {
Element.prototype.scrollIntoView = () => undefined
})
afterEach(unmountAll)
const SAVED_ORDER = [
'Title',
'Starts on',
'Ends on',
'Online',
'Capacity',
'Category',
'Description',
]
describe('What marks a saved field as changed', () => {
it.each([
['Capacity', async () => setMax('2000')],
[
'Title',
async () => {
await selectRow('Title')
await typeInto(labelInput(), 'Name of event')
},
],
[
'Starts on',
async () => {
await selectRow('Starts on')
await toggle('Required')
},
],
[
'Category',
async () => {
await selectRow('Category')
await addChoice('Panel')
},
],
[
'Online',
async () => {
await selectRow('Online')
await toggle('On by default')
},
],
])('marks %s Changed in its row and inspector', async (label, edit) => {
await mountAt('/schema/events')
await edit()
const row = rows().find((candidate) =>
text(candidate).includes(label === 'Title' ? 'Name of event' : label),
)
expect(text(row)).toContain('Changed')
expect(text(el('[data-testid="inspector-pane"] h2')!.parentElement)).toContain('Changed')
expect(chip()).toBe('Unsaved changes')
})
it('clears the badge when the original value comes back', async () => {
await mountAt('/schema/events')
await setMax('2000')
expect(badgesOf('Capacity')).toContain('Changed')
await typeInto(limit('max'), '5000')
expect(badgesOf('Capacity')).not.toContain('Changed')
expect(chip()).toBe('Saved')
expect(saveButton()!.disabled).toBe(true)
})
it('does not treat a reorder as a change of a field', async () => {
await mountAt('/schema/events')
await selectRow('Ends on')
await click(byLabel('Move up'))
expect(rowLabels().slice(0, 3)).toEqual(['Title', 'Ends on', 'Starts on'])
expect(rows().every((row) => !text(row).includes('Changed'))).toBe(true)
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
})
})
describe('Unsaved changes (design state 4)', () => {
it('shows the badges, the undo row, the count, the chip and keeps the notice', async () => {
await mountAt('/schema/events')
await makeUnsavedChanges()
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
expect(discardButton()!.disabled).toBe(false)
expect(badgesOf('Capacity')).toContain('Changed')
expect(badgesOf('Speaker')).toContain('New')
expect(badgesOf('Room')).toContain('New')
expect(undoRows()).toEqual(['Description will be removed Undo'])
expect(el('[data-testid="undo-row"] svg')).not.toBeNull()
expect(el('[data-testid="undo-row"]')!.className).toContain('border-dashed')
expect(text(el('[data-testid="field-count"]'))).toBe('8 of 50')
expect(rowLabels()).not.toContain('Description')
expect(notice()).toContain('37 entries')
})
})
describe('Removing a saved field and undoing it', () => {
it('selects the previous row at the end, then restores the field at position 7 with no badge', async () => {
await mountAt('/schema/events')
await selectRow('Description')
await removeSelected()
expect(rowLabels()).toEqual(SAVED_ORDER.slice(0, 6))
expect(inspectorTitle()).toBe('Field: Category')
expect(undoRows()).toEqual(['Description will be removed Undo'])
expect(chip()).toBe('Unsaved changes')
await click(undoButtons()[0])
expect(rowLabels()).toEqual(SAVED_ORDER)
expect(badgesOf('Description')).toBe('')
expect(undoRows()).toEqual([])
expect(chip()).toBe('Saved')
expect(saveButton()!.disabled).toBe(true)
})
it('selects the next row when a middle row is removed', async () => {
await mountAt('/schema/events')
await selectRow('Ends on')
await removeSelected()
expect(inspectorTitle()).toBe('Field: Online')
})
it('shows one undo row per removal, in removal order, each restoring its own place', async () => {
await mountAt('/schema/events')
await selectRow('Description')
await removeSelected()
await selectRow('Ends on')
await removeSelected()
expect(undoRows()).toEqual([
'Description will be removed Undo',
'Ends on will be removed Undo',
])
await click(undoButtons()[1])
expect(rowLabels()).toEqual([
'Title',
'Starts on',
'Ends on',
'Online',
'Capacity',
'Category',
])
await click(undoButtons()[0])
expect(rowLabels()).toEqual(SAVED_ORDER)
expect(chip()).toBe('Saved')
})
it('does not offer an undo for a field added and removed since the last save', async () => {
await mountAt('/schema/events')
await addField('Speaker')
await removeSelected()
expect(undoRows()).toEqual([])
expect(chip()).toBe('Saved')
})
})
describe('A removed key stays reserved', () => {
it('gives a new Description field the key description_2 and still restores description', async () => {
await mountAt('/schema/events')
await selectRow('Description')
await removeSelected()
await addField('Description')
expect(fieldKeyInput()!.value).toBe('description_2')
await click(undoButtons()[0])
const keys = all('[data-testid="field-row-key"]').map(text)
expect(keys).toContain('description')
expect(keys).toContain('description_2')
expect(new Set(keys).size).toBe(keys.length)
})
})
describe('Undo at the limit of 50 fields', () => {
it('disables Undo until a field is removed', async () => {
await mountAt('/schema/events')
await selectRow('Description')
await removeSelected()
for (let count = 0; count < 44; count++) {
await click(button('Add field'))
}
expect(text(el('[data-testid="field-count"]'))).toBe('50 of 50')
expect(undoButtons()[0]!.disabled).toBe(true)
await removeSelected()
expect(undoButtons()[0]!.disabled).toBe(false)
})
})
describe('Removing every field', () => {
it('shows the empty list with its error, the undo rows, one problem and a disabled Save', async () => {
await mountAt('/schema/events')
for (let count = 0; count < 7; count++) {
await removeSelected()
}
expect(el('[data-testid="field-empty"]')).not.toBeNull()
expect(text(el('[data-testid="field-empty"]'))).toContain('No fields yet')
expect(text(el('[data-testid="field-empty"]'))).toContain(
'A content type needs at least one field.',
)
expect(undoRows()).toHaveLength(7)
expect(chip()).toBe('1 problem to fix')
expect(saveButton()!.disabled).toBe(true)
})
})
describe('Name edit only', () => {
it('keeps the key, marks the form changed and leaves the sidebar and list alone', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
expect(keyInput()!.value).toBe('events')
expect(chip()).toBe('Unsaved changes')
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toContain(
'Events',
)
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).not.toContain(
'Events and talks',
)
expect(rows().every((row) => !text(row).includes('Changed'))).toBe(true)
})
it('flags an empty or blank name under Name and disables Save without sending', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), ' ')
expect(text(nameInput()!.closest('[data-orientation]'))).toContain(
'Give the content type a name.',
)
expect(nameInput()!.closest('[data-orientation]')!.querySelector('svg')).not.toBeNull()
expect(saveButton()!.disabled).toBe(true)
expect(puts).toHaveLength(0)
})
})
describe('Field errors (design state 5)', () => {
it('flags three rows, counts three problems and shows the four messages', async () => {
await mountAt('/schema/events')
await selectRow('Ends on')
await typeInto(labelInput(), '')
await selectRow('Capacity')
await typeInto(limit('min'), '6000')
await selectRow('Category')
await typeInto(all<HTMLInputElement>('[data-testid="choice-row"] input')[2], '')
await addChoice('Talk')
expect(chip()).toBe('3 problems to fix')
expect(saveButton()!.disabled).toBe(true)
expect(all('[data-testid="field-row-alert"]')).toHaveLength(3)
expect(text(inspector())).toContain("Choices can't be blank.")
expect(text(inspector())).toContain('Two choices are the same.')
await selectRow('Capacity')
expect(text(inspector())).toContain("Lowest value can't be above the highest value.")
await selectRow('Untitled field')
expect(text(inspector())).toContain('Give the field a label.')
})
it('shows a single key error for a new field and none for a saved field', async () => {
await mountAt('/schema/events')
await addField('Start date')
await click(
fieldKeyInput()!
.closest<HTMLElement>('[data-orientation]')!
.querySelector('button[aria-label="Edit key"]'),
)
await typeInto(fieldKeyInput(), 'start-date')
expect(text(inspector())).toContain(
'Start with a letter. Use lowercase letters, digits and underscores.',
)
expect(chip()).toBe('1 problem to fix')
await selectRow('Title')
expect(text(inspector())).not.toContain('Start with a letter')
})
})
describe('The choice list of a saved Choice field (design state 8)', () => {
it('lists the saved choices with the removal warning, and works like the create list', async () => {
await mountAt('/schema/events')
await selectRow('Category')
expect(
all<HTMLInputElement>('[data-testid="choice-row"] input').map((input) => input.value),
).toEqual(['Talk', 'Workshop', 'Meetup'])
expect(text(inspector())).toContain(
'Removing a choice that entries use will be refused when you save.',
)
await addChoice('Panel')
expect(text(el('[data-testid="choice-count"]'))).toBe('4 of 50')
await chooseDefault('Workshop')
expect(text(el('button[role="combobox"]'))).toContain('Workshop')
await click(byLabel('Remove Workshop'))
expect(text(el('button[role="combobox"]'))).toContain('None')
expect(defaultInput()).toBeNull()
})
})
describe('Keyboard reorder in edit mode (design state 9)', () => {
const grip = (label: string) => byLabel(`Move ${label}`)!
it('announces the move from position 3, restores on Esc and drops on Space', async () => {
await mountAt('/schema/events')
await press(grip('Ends on'), ' ')
await press(grip('Ends on'), 'ArrowUp')
expect(live()).toBe(
'Moving Ends on. Position 2 of 7. Press Up or Down to move, Space to drop, Esc to cancel.',
)
await press(grip('Ends on'), 'Escape')
expect(rowLabels()).toEqual(SAVED_ORDER)
await press(grip('Ends on'), ' ')
await press(grip('Ends on'), 'ArrowUp')
await press(grip('Ends on'), ' ')
expect(rowLabels().slice(0, 3)).toEqual(['Title', 'Ends on', 'Starts on'])
expect(chip()).toBe('Unsaved changes')
})
})
describe('API preview in edit mode (design state 10)', () => {
it('uses the locked key, follows the fields and keeps the note', async () => {
await mountAt('/schema/events')
await click(el('[data-testid="api-preview-toggle"]'))
expect(all('[data-testid="api-route"]').map(text)[0]).toBe(
'GET /v1/content-type/events/entry',
)
expect(text(el('[data-testid="api-json"]'))).toContain('"description"')
expect(text(document.body)).toContain(
'Updates as you edit. Fields you add or remove here appear after you save.',
)
await selectRow('Description')
await removeSelected()
await addField('Speaker')
const json = text(el('[data-testid="api-json"]'))
expect(json).not.toContain('"description"')
expect(json).toContain('"speaker"')
expect(json).toContain('"content_type": "events"')
})
})
describe('Discard', () => {
it('returns to the loaded type with the first row selected and nothing sent', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Other')
await setMax('2000')
await addField('Speaker')
await selectRow('Description')
await removeSelected()
await click(discardButton())
expect(nameInput()!.value).toBe('Events')
expect(rowLabels()).toEqual(SAVED_ORDER)
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
expect(undoRows()).toEqual([])
expect(inspectorTitle()).toBe('Field: Title')
expect(chip()).toBe('Saved')
expect(puts).toHaveLength(0)
expect(router.currentRoute.value.path).toBe('/schema/events')
})
})
describe('Leaving with unsaved edits in edit mode', () => {
const DIALOG_TITLE = 'Leave without saving?'
it('navigates with no prompt when nothing was edited', async () => {
await mountAt('/schema/events')
await router.push('/schema')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
expect(text(document.body)).not.toContain(DIALOG_TITLE)
})
it('asks first for an edit, even one only in an undo row', async () => {
await mountAt('/schema/events')
await selectRow('Description')
await removeSelected()
const leaving = router.push('/schema')
await flushPromises()
expect(text(document.body)).toContain(DIALOG_TITLE)
await click(button('Keep editing'))
await leaving
expect(router.currentRoute.value.path).toBe('/schema/events')
const again = router.push('/schema')
await flushPromises()
await click(button('Discard changes'))
await again
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
})
it('navigates with no prompt after a Discard', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Other')
await click(discardButton())
await router.push('/schema')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
})
})
describe('Mobile field sheet in edit mode', () => {
it('opens Field: Capacity with the locked key, and Remove closes it and shows the undo row', async () => {
await mountAt('/schema/events', { width: MOBILE_WIDTH })
await click(rows()[4]!.querySelector('[data-field-row]'))
const sheet = el('[role="dialog"]')!
expect(text(sheet)).toContain('Field: Capacity')
expect(sheet.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
expect(byLabel('Move up')).not.toBeNull()
expect(byLabel('Move down')).not.toBeNull()
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
await click(byLabel('Remove'))
await vi.waitFor(() => expect(el('[role="dialog"]')).toBeNull())
expect(undoRows()).toEqual(['Capacity will be removed Undo'])
})
it('shows the bottom bar with the chip, Discard and Save', async () => {
await mountAt('/schema/events', { width: MOBILE_WIDTH })
await typeInto(nameInput(), 'Other')
expect(text(el('.sticky'))).toBe('Unsaved changes Discard Save')
})
})
@@ -0,0 +1,448 @@
import { afterEach, 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 { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import {
button,
calls,
entryPage,
failWith,
mountAt,
networkFailure,
pending,
text,
unmountAll,
} from './schemaTestKit'
import {
all,
badgesOf,
chip,
click,
discardButton,
el,
fieldKeyInput,
inspector,
inspectorTitle,
keyInput,
limit,
main,
nameInput,
notice,
pickTile,
rowLabels,
rows,
saveButton,
selectRow,
tile,
typeInto,
} from './builderKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountAll)
const SKELETON_DELAY_MS = 150
const NOT_FOUND = 'Content type not found'
const COUNT_ERROR = "Couldn't load content type"
const COUNT_LINE = 'GET /v1/content-type/events/entry failed. Check your connection and try again.'
describe('Resolving the key of /schema/:key', () => {
it('opens the builder in edit mode for a custom type', async () => {
await mountAt('/schema/events')
expect(nameInput()!.value).toBe('Events')
expect(keyInput()!.value).toBe('events')
expect(rowLabels()).toHaveLength(7)
})
it('keeps the read-only view for a built-in type', async () => {
await mountAt('/schema/articles')
expect(main().querySelector('h1')?.textContent).toBe('Articles')
expect(nameInput()).toBeNull()
})
it('says not found for an unknown key', async () => {
await mountAt('/schema/unknown')
expect(text(main())).toContain(NOT_FOUND)
expect(nameInput()).toBeNull()
expect(calls.some((url) => url.includes('/entry'))).toBe(false)
})
it('keeps /schema/new as the create builder', async () => {
await mountAt('/schema/new')
expect(nameInput()!.value).toBe('')
expect(keyInput()!.readOnly).toBe(true)
expect(rowLabels()).toEqual(['Title'])
})
})
describe('Loading, the session store and the entry count', () => {
it('calls each list once and the count once, and never reads the type on its own', async () => {
await mountAt('/schema/events')
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
expect(
calls.filter((url) => url === '/v1/content-type/events/entry?page=1&size=5'),
).toHaveLength(1)
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(0)
})
it('shows the skeleton, and no builder, until the count is known', async () => {
vi.useFakeTimers()
const gate = pending()
await mountAt('/schema/events', { entries: () => gate.promise })
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
).toBeGreaterThan(0)
expect(nameInput()).toBeNull()
expect(saveButton()).toBeUndefined()
gate.resolve(entryPage(37))
await flushPromises()
expect(nameInput()!.value).toBe('Events')
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
})
it('shows the skeleton until the built-in list is known', async () => {
vi.useFakeTimers()
const gate = pending()
await mountAt('/schema/events', { builtin: () => gate.promise })
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(nameInput()).toBeNull()
expect(calls.filter((url) => url.includes('/entry'))).toHaveLength(0)
gate.resolve([])
await flushPromises()
expect(nameInput()!.value).toBe('Events')
})
})
describe('The count cannot be loaded', () => {
it.each([
['answers 500', failWith(500)],
['fails (network)', networkFailure],
])('shows the error when the count %s, then opens on Try again', async (_, failure) => {
let respond = failure as () => unknown
await mountAt('/schema/events', { entries: () => respond() })
expect(text(main())).toContain(COUNT_ERROR)
expect(text(main())).toContain(COUNT_LINE)
expect(nameInput()).toBeNull()
expect(saveButton()).toBeUndefined()
respond = () => entryPage(37)
await click(button('Try again'))
expect(nameInput()!.value).toBe('Events')
expect(
calls.filter((url) => url === '/v1/content-type/events/entry?page=1&size=5'),
).toHaveLength(2)
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
})
it('says not found when the count answers 404', async () => {
await mountAt('/schema/events', { entries: failWith(404) })
expect(text(main())).toContain(NOT_FOUND)
expect(nameInput()).toBeNull()
})
it('shows the access page when the count answers 403', async () => {
await mountAt('/schema/events', { entries: failWith(403) })
expect(text(main())).toContain("You don't have access to Schema")
expect(nameInput()).toBeNull()
})
it('shows the list error when the custom list fails, and retries it', async () => {
let respond = failWith(500) as () => unknown
await mountAt('/schema/events', { content: () => respond() })
expect(text(main())).toContain("Couldn't load content types")
expect(nameInput()).toBeNull()
const { customTypes } = await import('./schemaTestKit')
respond = () => [...customTypes]
await click(button('Try again'))
expect(nameInput()!.value).toBe('Events')
})
})
describe('Roles and sessions in edit mode', () => {
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
await mountAt('/schema/events', { role })
expect(nameInput()!.value).toBe('Events')
})
it('tells a verified user who is neither to go home, leaving the URL', async () => {
await mountAt('/schema/events', { role: 'user' })
expect(text(main())).toContain("You don't have access to Schema")
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
expect(router.currentRoute.value.path).toBe('/schema/events')
expect(calls.filter((url) => url.includes('/entry'))).toHaveLength(0)
})
it('redirects an unverified user to /register/pending', async () => {
await mountAt('/schema/events', { verified: false })
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('redirects a signed-out visitor to /login', async () => {
await mountAt('/schema/events')
useSessionStore().clear()
await router.push('/schema/events')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('Session-owned store', () => {
it('resets both lists on sign-out and reloads them for the next session', async () => {
await mountAt('/schema/events')
const store = useContentTypesStore()
expect(store.items).toHaveLength(3)
expect(store.builtinItems).toHaveLength(5)
useSessionStore().clear()
await flushPromises()
expect(store.items).toEqual([])
expect(store.builtinItems).toEqual([])
expect(store.status).toBe('idle')
expect(store.builtinStatus).toBe('idle')
})
it('starts a second visit from the store, not from an old draft', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
await unmountAll()
await mountAt('/schema/events')
expect(nameInput()!.value).toBe('Events')
expect(chip()).toBe('Saved')
})
})
describe('Editing an existing type, desktop (design state 2)', () => {
it('shows the breadcrumb, a locked key, the chip, the notice and the list', async () => {
await mountAt('/schema/events')
const crumbs = text(document.body.querySelector('nav[aria-label="breadcrumb"]'))
expect(crumbs).toContain('Schema')
expect(crumbs).toContain('Events')
expect(
document.body.querySelector('nav[aria-label="breadcrumb"] a[href="/schema"]'),
).not.toBeNull()
expect(nameInput()!.readOnly).toBe(false)
expect(nameInput()!.disabled).toBe(false)
expect(keyInput()!.readOnly).toBe(true)
expect(keyInput()!.className).toContain('font-mono')
expect(el('[data-testid="slug-lock"]')).not.toBeNull()
expect(document.body.querySelector('button[aria-label="Edit key"]')).toBeNull()
expect(text(main())).not.toContain('Auto')
expect(text(main())).not.toContain('Reset to match the name')
expect(chip()).toBe('Saved')
expect(notice()).toBe(
'37 entries use this content type. Changes are checked against them when you save.',
)
expect(text(el('[data-testid="field-count"]'))).toBe('7 of 50')
expect(rowLabels()).toEqual([
'Title',
'Starts on',
'Ends on',
'Online',
'Capacity',
'Category',
'Description',
])
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
expect(inspectorTitle()).toBe('Field: Title')
expect(text(el('[data-testid="inspector-pane"] h2')!.parentElement)).not.toContain('New')
expect(saveButton()!.disabled).toBe(true)
expect(discardButton()!.disabled).toBe(true)
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
'false',
)
})
it('puts the notice under the type bar and above the list', async () => {
await mountAt('/schema/events')
const bar = el('[data-testid="entries-notice"]')!
const header = el('[data-testid="field-count"]')!
expect(bar.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(
nameInput()!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
})
describe('Editing an existing type, mobile (design state 2)', () => {
it('stacks the type bar, the notice, the rows and the API row, with the bottom bar', async () => {
await mountAt('/schema/events', { width: MOBILE_WIDTH })
expect(text(main())).not.toContain('Field: Title')
expect(el('[data-testid="inspector-pane"]')).toBeNull()
const order = [
nameInput(),
keyInput(),
el('[data-testid="entries-notice"]'),
rows()[0],
el('[data-testid="api-row"]'),
]
order.forEach((node, index) => {
expect(node).not.toBeNull()
if (index > 0) {
expect(
order[index - 1]!.compareDocumentPosition(node!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
}
})
const bar = el('.sticky')!
expect(text(bar)).toBe('Saved Discard Save')
expect(text(document.body)).toContain('Events')
})
})
describe('The notice follows the count', () => {
it.each([
[0, null],
[1, '1 entry uses this content type. Changes are checked against it when you save.'],
[37, '37 entries use this content type. Changes are checked against them when you save.'],
[
1200,
'1,200 entries use this content type. Changes are checked against them when you save.',
],
])('for %s entries', async (total, copy) => {
await mountAt('/schema/events', { entries: () => entryPage(total) })
if (copy === null) {
expect(el('[data-testid="entries-notice"]')).toBeNull()
} else {
expect(notice()).toBe(copy)
}
})
})
describe('The locked type key (design state 3)', () => {
const TIP = "The key is used in URLs and the API, so it can't change after the type is created."
it('opens a tooltip on focus, closes it on blur and Esc, and refuses typing', async () => {
await mountAt('/schema/events')
keyInput()!.focus()
await flushPromises()
expect(text(document.body)).toContain(TIP)
keyInput()!.blur()
await flushPromises()
expect(text(document.body)).not.toContain(TIP)
keyInput()!.focus()
await flushPromises()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(text(document.body)).not.toContain(TIP)
await typeInto(keyInput(), 'changed')
expect(keyInput()!.readOnly).toBe(true)
expect(text(main())).not.toContain('Use 2 to 40 lowercase letters')
})
})
describe('Saved fields are locked', () => {
it('shows the lock, the help and the dimmed tiles for a saved field', async () => {
await mountAt('/schema/events')
await selectRow('Starts on')
expect(fieldKeyInput()!.value).toBe('starts_on')
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
expect(text(inspector())).toContain("Used in the API, so it can't change.")
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
expect(text(inspector())).toContain("Can't change after saving")
expect(all('[role="radio"]')).toHaveLength(6)
expect(tile('Date').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Date'),
)
expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
true,
)
expect(tile('Date').hasAttribute('data-disabled')).toBe(false)
})
it('does not change the type when another tile is activated', async () => {
await mountAt('/schema/events')
await selectRow('Starts on')
await pickTile('Number')
expect(tile('Date').getAttribute('data-state')).toBe('checked')
expect(chip()).toBe('Saved')
})
it('keeps label, Required, Unique, default and limits editable', async () => {
await mountAt('/schema/events')
await selectRow('Capacity')
expect(limit('max')!.disabled).toBe(false)
expect(limit('min')!.disabled).toBe(false)
expect(el<HTMLInputElement>('input[name="label"]')!.disabled).toBe(false)
expect(el<HTMLInputElement>('input[name="default"]')!.disabled).toBe(false)
})
})
describe('Fields added since the last save', () => {
it('selects a new row with New, an Auto key, six selectable tiles and no lock', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
expect(inspectorTitle()).toBe('Field: Untitled field')
expect(text(el('[data-testid="inspector-pane"]')!)).toContain('New')
expect(badgesOf('Untitled field')).toContain('New')
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).toBeNull()
expect(text(inspector())).not.toContain("Can't change after saving")
expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
true,
)
})
it('derives a key for a typed label that does not collide with a saved key', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'Title')
expect(fieldKeyInput()!.value).toBe('title_2')
expect(text(inspector())).toContain('Auto')
expect(chip()).toBe('Unsaved changes')
})
})
@@ -0,0 +1,846 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import router from '@/router'
import BuilderActions from '@/components/BuilderActions.vue'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { useContentTypesStore } from '@/stores/contentTypes'
import {
button,
calls,
eventsFields,
failWithBody,
mountAt,
networkFailure,
pending,
puts,
text,
unmountAll,
updatedType,
} from './schemaTestKit'
import {
addField,
all,
badgesOf,
byLabel,
chip,
click,
dialog,
discardButton,
el,
fieldKeyInput,
keyInput,
labelInput,
main,
makeUnsavedChanges,
nameInput,
notice,
rowLabels,
rows,
saveButton,
selectRow,
setMax,
typeInto,
undoButtons,
undoRows,
} from './builderKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
beforeEach(() => {
Element.prototype.scrollIntoView = () => undefined
})
afterEach(async () => {
vi.restoreAllMocks()
await unmountAll()
})
const SAVE_FAILED_TITLE = "Couldn't save the content type"
const SAVE_FAILED_TEXT =
'Something went wrong on our side. Your changes are still here. Try again in a moment.'
const SAVED_AT = /^Saved at \d{2}:\d{2}$/
const FIELD_KEYS = [
'key',
'label',
'type',
'required',
'unique',
'default',
'min',
'max',
'options',
]
async function editRecipes() {
await selectRow('Servings')
await typeInto(labelInput(), 'Portions')
}
describe('Saving a type that has no entries', () => {
it('sends one PUT with no review and exactly name, icon and fields', async () => {
await mountAt('/schema/recipes')
await editRecipes()
await click(saveButton())
expect(puts).toHaveLength(1)
expect(puts[0]!.url).toBe('/v1/content-type/recipes')
expect(dialog()).toBeNull()
const body = puts[0]!.body as {
name: string
icon: unknown
fields: Record<string, unknown>[]
}
expect(Object.keys(body)).toEqual(['name', 'icon', 'fields'])
expect(body.icon).toBeNull()
expect(body.fields.map((field) => field.label)).toEqual([
'Title',
'Portions',
'Vegetarian',
'Method',
])
expect(body.fields.every((field) => Object.keys(field).join() === FIELD_KEYS.join())).toBe(
true,
)
})
})
describe('The PUT body', () => {
it('keeps the loaded icon, sends no key, trims the name and keeps the saved keys and types', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), ' Events and talks ')
await click(saveButton())
expect(puts).toHaveLength(1)
const body = puts[0]!.body as {
name: string
icon: unknown
fields: Record<string, unknown>[]
}
expect(Object.keys(body)).toEqual(['name', 'icon', 'fields'])
expect(body.name).toBe('Events and talks')
expect(body.icon).toBe('files')
expect('key' in body).toBe(false)
expect(body.fields.map((field) => [field.key, field.type])).toEqual(
eventsFields.map((field) => [field.key, field.type]),
)
expect(body.fields[3]).toMatchObject({
key: 'online',
default: null,
options: [],
})
expect(body.fields[4]).toMatchObject({ min: 1, max: 5000 })
expect(body.fields[5]).toMatchObject({
options: ['Talk', 'Workshop', 'Meetup'],
})
})
it('keeps a null icon null and leaves undone removals out', async () => {
await mountAt('/schema/recipes')
await selectRow('Method')
await click(byLabel('Remove'))
await click(saveButton())
const body = puts[0]!.body as {
icon: unknown
fields: { key: string }[]
}
expect(body.icon).toBeNull()
expect(body.fields.map((field) => field.key)).toEqual(['title', 'servings', 'vegetarian'])
})
})
describe('Saving (design state 11, no entries)', () => {
async function startSaving() {
const gate = pending()
await mountAt('/schema/recipes', { update: () => gate.promise })
await editRecipes()
await selectRow('Method')
await click(byLabel('Remove'))
await click(saveButton())
return gate
}
it('shows the chip and the loader, dims and disables everything, and sends once', async () => {
await startSaving()
expect(chip()).toBe('Saving…')
expect(el('[data-testid="builder-chip"] svg')).not.toBeNull()
expect(saveButton()).toBeUndefined()
const saving = button('Saving…') as HTMLButtonElement
expect(saving.disabled).toBe(true)
expect(saving.querySelector('svg')).not.toBeNull()
expect(discardButton()!.disabled).toBe(true)
expect(nameInput()!.disabled).toBe(true)
expect(labelInput()!.disabled).toBe(true)
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
expect(all<HTMLButtonElement>('[data-grip]').every((grip) => grip.disabled)).toBe(true)
expect((byLabel('Move up') as HTMLButtonElement).disabled).toBe(true)
expect((byLabel('Remove') as HTMLButtonElement).disabled).toBe(true)
expect(undoButtons().every((undo) => undo.disabled)).toBe(true)
saving.click()
await flushPromises()
expect(puts).toHaveLength(1)
})
it('ignores a second Save however it is activated', async () => {
const gate = pending()
const mounted = await mountAt('/schema/recipes', {
update: () => gate.promise,
})
await editRecipes()
await click(saveButton())
const actions = mounted.findComponent(BuilderActions)
actions.vm.$emit('save')
actions.vm.$emit('save')
await flushPromises()
expect(puts).toHaveLength(1)
})
})
describe('Saved (design state 12)', () => {
it('toasts, stays, clears the marks, locks the new fields and reads Saved at', async () => {
await mountAt('/schema/recipes')
await editRecipes()
await click(button('Add field'))
await typeInto(labelInput(), 'Notes')
await selectRow('Method')
await click(byLabel('Remove'))
await click(saveButton())
await flushPromises()
expect(text(document.body)).toContain('Content type saved')
expect(text(document.body)).toContain('Recipes now has 4 fields.')
expect(router.currentRoute.value.path).toBe('/schema/recipes')
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
expect(undoRows()).toEqual([])
expect(chip()).toMatch(SAVED_AT)
expect(saveButton()!.disabled).toBe(true)
expect(discardButton()!.disabled).toBe(true)
await selectRow('Notes')
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(el('[data-testid="field-inspector"] [data-testid="slug-lock"]')).not.toBeNull()
expect(text(el('[data-testid="field-inspector"]'))).toContain("Can't change after saving")
})
it('writes the answered type into the store, the list and the sidebar with no second GET', async () => {
await mountAt('/schema/events', {
update: (body) => ({
...updatedType('events', body),
name: 'Events and talks',
}),
})
await typeInto(nameInput(), 'Events and talks')
await click(saveButton())
await flushPromises()
const store = useContentTypesStore()
expect(store.items[0]!.name).toBe('Events and talks')
expect(el('nav a[href="/content/events"]')!.textContent).toContain('Events and talks')
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
await router.push('/schema')
await flushPromises()
expect(
all('[data-testid="schema-row"]').some((row) => text(row).includes('Events and talks')),
).toBe(true)
})
it('reads "1 field" for a single field', async () => {
await mountAt('/schema/recipes')
for (let count = 0; count < 3; count++) {
await click(byLabel('Remove'))
}
await click(saveButton())
await flushPromises()
expect(text(document.body)).toContain('Recipes now has 1 field.')
})
it('does not prompt when leaving afterwards', async () => {
await mountAt('/schema/recipes')
await editRecipes()
await click(saveButton())
await flushPromises()
await router.push('/schema')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
expect(text(document.body)).not.toContain('Leave without saving?')
})
it('goes back to Unsaved changes on an edit and Discard returns to the last saved state', async () => {
await mountAt('/schema/recipes')
await editRecipes()
await click(saveButton())
await flushPromises()
await typeInto(nameInput(), 'Dishes')
expect(chip()).toBe('Unsaved changes')
await click(discardButton())
expect(nameInput()!.value).toBe('Recipes')
expect(rowLabels()).toEqual(['Title', 'Portions', 'Vegetarian', 'Method'])
expect(chip()).toMatch(SAVED_AT)
})
})
describe('Save on a type with entries opens the review (group 3, state 1)', () => {
it('sends nothing yet and opens the dialog with the summary, the rows in order and the note', async () => {
await mountAt('/schema/events')
await makeUnsavedChanges()
await click(saveButton())
expect(puts).toHaveLength(0)
const open = dialog()!
expect(text(open)).toContain('Review changes to Events')
expect(text(open)).toContain('Apply them to save the content type.')
expect(text(el('[data-testid="review-summary"]'))).toBe('Add 2 fields, change 1, remove 1')
expect(text(open)).toContain(
'Events has 37 entries. Each one is checked against the new fields when you apply.',
)
expect(all('[data-testid="change-title"]').map(text)).toEqual([
'Add Speaker',
'Add Room',
'Change Capacity',
'Remove Description',
])
expect(all('[data-testid="change-text"]').map(text)).toEqual([
'Text, optional.',
'Choice, required, default "Main hall". Existing entries get the default, so they still fit.',
'Highest value goes from 5000 to 2000.',
'Entries stop returning its values.',
])
expect(all('[data-testid="change-flag"]').map(text)).toEqual([
'Entries may not fit',
'Removes data',
])
expect(text(open)).toContain(
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
)
expect(button('Back to editing')).toBeInstanceOf(HTMLButtonElement)
expect(button('Apply changes')).toBeInstanceOf(HTMLButtonElement)
})
it('opens as a bottom sheet on mobile', async () => {
await mountAt('/schema/events', { width: MOBILE_WIDTH })
await click(rows()[6]!.querySelector('[data-field-row]'))
await click(byLabel('Remove'))
await vi.waitFor(() => expect(el('[data-testid="field-sheet-title"]')).toBeNull())
await click(saveButton())
expect(dialog()).not.toBeNull()
expect(dialog()!.className).toContain('max-sm:')
expect(text(dialog())).toContain('Review changes to Events')
})
})
describe('When the review opens', () => {
it('sends a name-only edit straight away', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
await click(saveButton())
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it('sends a reorder-only edit straight away', async () => {
await mountAt('/schema/events')
await selectRow('Ends on')
await click(byLabel('Move up'))
await click(saveButton())
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it.each([
['a field added', async () => addField('Speaker')],
['a field changed', async () => setMax('2000')],
[
'a field removed',
async () => {
await selectRow('Description')
await click(byLabel('Remove'))
},
],
])('opens for %s', async (_, edit) => {
await mountAt('/schema/events')
await edit()
await click(saveButton())
expect(dialog()).not.toBeNull()
expect(puts).toHaveLength(0)
})
it('lists only the field change when the name changed too', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
await setMax('2000')
await click(saveButton())
expect(all('[data-testid="change-title"]').map(text)).toEqual(['Change Capacity'])
expect(text(el('[data-testid="review-summary"]'))).toBe('Change 1')
})
})
describe('The review title after a rename', () => {
it('reads the saved name, not the name loaded with the page', async () => {
await mountAt('/schema/events')
await typeInto(nameInput()!, 'Seminars')
await setMax('2000')
await click(saveButton())
await click(button('Apply changes'))
await flushPromises()
await setMax('3000')
await click(saveButton())
expect(text(dialog()!)).toContain('Review changes to Seminars')
})
})
describe('Back to editing from the review', () => {
it('closes with the button and keeps every edit', async () => {
await mountAt('/schema/events')
await makeUnsavedChanges()
await click(saveButton())
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(puts).toHaveLength(0)
expect(chip()).toBe('Unsaved changes')
expect(badgesOf('Speaker')).toContain('New')
expect(undoRows()).toHaveLength(1)
})
it('closes on Esc', async () => {
await mountAt('/schema/events')
await setMax('2000')
await click(saveButton())
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(puts).toHaveLength(0)
})
it('returns the focus to Save', async () => {
await mountAt('/schema/events')
await setMax('2000')
const save = saveButton()!
save.focus()
await click(save)
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(document.activeElement).toBe(save)
})
})
describe('Applying (group 3, state 2) and Applied', () => {
async function apply() {
const gate = pending()
await mountAt('/schema/events', { update: () => gate.promise })
await setMax('2000')
await click(saveButton())
await click(button('Apply changes'))
return gate
}
it('sends one PUT, reads Applying… with a loader and cannot be dismissed', async () => {
await apply()
expect(puts).toHaveLength(1)
const confirm = button('Applying…') as HTMLButtonElement
expect(confirm.disabled).toBe(true)
expect(confirm.querySelector('svg')).not.toBeNull()
expect((button('Back to editing') as HTMLButtonElement).disabled).toBe(true)
expect(dialog()!.getAttribute('aria-busy')).toBe('true')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(dialog()).not.toBeNull()
confirm.click()
await flushPromises()
expect(puts).toHaveLength(1)
})
it('keeps the focus on the title of the dialog', async () => {
await apply()
await vi.waitFor(() =>
expect(document.activeElement?.textContent).toBe('Review changes to Events'),
)
})
it('closes the dialog on success with the toast, the cleared marks and Saved at', async () => {
const gate = await apply()
gate.resolve(updatedType('events', puts[0]!.body))
await flushPromises()
expect(dialog()).toBeNull()
expect(text(document.body)).toContain('Content type saved')
expect(text(document.body)).toContain('Events now has 7 fields.')
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
expect(chip()).toMatch(SAVED_AT)
expect(notice()).toContain('37 entries')
expect(text(document.body)).not.toContain('Leave without saving?')
})
})
describe('Refused (design state 15 and group 3, state 3)', () => {
const REFUSAL_TEXT =
"RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries."
async function refused(
edit: () => Promise<void> = async () => {
await setMax('2000')
await selectRow('Description')
await click(byLabel('Remove'))
},
) {
await mountAt('/schema/events', {
update: failWithBody(409, 'schema_incompatible'),
})
await edit()
await click(saveButton())
await click(button('Apply changes'))
}
it('replaces the review with the refusal dialog that lists only the flagged change', async () => {
await refused()
const open = dialog()!
expect(text(open)).toContain('Nothing was changed')
expect(text(open)).toContain(REFUSAL_TEXT)
expect(text(open)).toContain('Your content type is as it was')
expect(text(open)).toContain('No field was added, changed or removed.')
expect(text(open)).toContain('The change that can cause this in your edit')
expect(all('[data-testid="change-title"]').map(text)).toEqual(['Change Capacity'])
expect(text(el('[data-testid="change-text"]'))).toBe(
'Highest value goes from 5000 to 2000. An entry may have a higher value.',
)
expect(text(el('[data-testid="change-flag"]'))).toBe('Possible cause')
expect(text(open)).not.toContain('Description')
expect(text(open)).toContain(
'This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use.',
)
expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement)
expect(button('Back to editing')).toBeInstanceOf(HTMLButtonElement)
expect(text(open)).not.toContain('37')
const store = useContentTypesStore()
expect(store.items[0]!.fields).toHaveLength(7)
expect((store.items[0]!.fields[4] as { max: number }).max).toBe(5000)
expect(nameInput()).not.toBeNull()
})
it('omits the cause section when no change is flagged', async () => {
await refused(async () => {
await selectRow('Description')
await click(byLabel('Remove'))
})
const open = dialog()!
expect(text(open)).toContain('Nothing was changed')
expect(text(open)).toContain('Your content type is as it was')
expect(text(open)).not.toContain('The change that can cause this in your edit')
expect(el('[data-testid="refusal-causes"]')).toBeNull()
expect(text(open)).toContain('This kind of change is refused')
})
it('returns to a banner with Review changes, marks the flagged row and keeps Save', async () => {
await refused()
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(text(main())).toContain('Nothing was changed')
expect(text(main())).toContain(
"Some entries don't fit the new fields, so the whole change was refused. RenewCMS can't tell which entries. The changes that can cause this are marked.",
)
expect(badgesOf('Capacity')).toContain('Changed')
expect(badgesOf('Capacity')).toContain('Possible cause')
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
await click(button('Review changes'))
expect(text(dialog())).toContain('Review changes to Events')
expect(all('[data-testid="change-title"]').map(text)).toEqual([
'Change Capacity',
'Remove Description',
])
})
it('also returns to the banner on Esc or the close icon', async () => {
await refused()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(text(main())).toContain('Nothing was changed')
})
it('resets the form on Discard changes, with no banner and no marker', async () => {
await refused()
await click(button('Discard changes'))
await vi.waitFor(() => expect(dialog()).toBeNull())
await flushPromises()
expect(text(main())).not.toContain('Nothing was changed')
expect(chip()).toBe('Saved')
expect(rowLabels()).toHaveLength(7)
expect(badgesOf('Capacity')).toBe('')
})
it('loses the marker when the flagged change is reverted, and the banner on success', async () => {
await refused()
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
await selectRow('Capacity')
await typeInto(el('input[name="max"]'), '5000')
expect(badgesOf('Capacity')).not.toContain('Possible cause')
expect(badgesOf('Description')).toBe('')
expect(text(main())).toContain('Nothing was changed')
})
it('clears the banner and every marker once a PUT succeeds', async () => {
let answer: (body: Record<string, unknown>) => unknown = () => {
throw Object.assign(new Error('refused'), {
status: 409,
body: 'schema_incompatible',
})
}
const { ApiError } = await import('@/composables/useApi')
answer = () => {
throw new ApiError(409, undefined, 'schema_incompatible')
}
await mountAt('/schema/events', { update: (body) => answer(body) })
await setMax('2000')
await click(saveButton())
await click(button('Apply changes'))
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(badgesOf('Capacity')).toContain('Possible cause')
answer = (body) => updatedType('events', body)
await click(button('Review changes'))
await click(button('Apply changes'))
await flushPromises()
expect(text(main())).not.toContain('Nothing was changed')
expect(badgesOf('Capacity')).toBe('')
expect(chip()).toMatch(SAVED_AT)
})
})
describe('Other answers of the PUT', () => {
async function answered(failure: () => unknown, edit = async () => setMax('2000')) {
await mountAt('/schema/events', { update: failure })
await edit()
await click(saveButton())
if (dialog()) {
await click(button('Apply changes'))
}
}
it('closes the review and shows the banner for 422 invalid_field, keeping the edits', async () => {
await answered(failWithBody(422, 'invalid_field'))
expect(dialog()).toBeNull()
expect(text(main())).toContain('Check the fields marked in the list.')
expect(badgesOf('Capacity')).toContain('Changed')
expect(saveButton()!.disabled).toBe(false)
})
it('shows the name message for 400 and keeps the edits', async () => {
await answered(failWithBody(400, 'Bad request body'))
expect(dialog()).toBeNull()
expect(text(nameInput()!.closest('[data-orientation]'))).toContain(
'Give the content type a name.',
)
expect(badgesOf('Capacity')).toContain('Changed')
expect(puts).toHaveLength(1)
})
it('keeps the name message off a 400 that is about the key', async () => {
await answered(failWithBody(400, 'invalid_content_type_key'))
expect(dialog()).toBeNull()
expect(text(nameInput()!.closest('[data-orientation]'))).not.toContain(
'Give the content type a name.',
)
expect(badgesOf('Capacity')).toContain('Changed')
})
it('shows the not-found page for 404 and drops the type from the store', async () => {
await answered(failWithBody(404, 'content_type_not_found'))
expect(text(main())).toContain('Content type not found')
expect(useContentTypesStore().items.map((type) => type.key)).toEqual([
'recipes',
'press-mentions',
])
expect(nameInput()).toBeNull()
})
it('shows the access page for 403', async () => {
await answered(failWithBody(403, 'Only owners and admins'))
expect(text(main())).toContain("You don't have access to Schema")
expect(nameInput()).toBeNull()
})
it('leaves the redirect to the API client for 401', async () => {
await answered(failWithBody(401, ''))
expect(dialog()).toBeNull()
expect(text(main())).not.toContain(SAVE_FAILED_TITLE)
})
})
describe('Save failed (design state 13)', () => {
it.each([
['answers 500', failWithBody(500, 'boom')],
['fails (network)', networkFailure],
])(
'closes the review, keeps the edits and retries through the review when the PUT %s',
async (_, failure) => {
let respond: () => unknown = failure
await mountAt('/schema/events', {
update: (body) => respond() ?? body,
})
await setMax('2000')
await click(saveButton())
await click(button('Apply changes'))
expect(dialog()).toBeNull()
expect(text(main())).toContain(SAVE_FAILED_TITLE)
expect(text(main())).toContain(SAVE_FAILED_TEXT)
expect(chip()).toBe('Save failed')
expect(badgesOf('Capacity')).toContain('Changed')
expect(saveButton()!.disabled).toBe(false)
respond = () => undefined
const first = puts[0]!.body
await click(button('Try again'))
expect(dialog()).not.toBeNull()
expect(puts).toHaveLength(1)
await click(button('Apply changes'))
expect(puts).toHaveLength(2)
expect(puts[1]!.body).toEqual(first)
},
)
})
describe('Offline (design state 16)', () => {
function setOnline(value: boolean) {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
}
it('shows the banner and the chip, disables Save, keeps the edits and sends nothing on reconnect', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
setOnline(false)
await flushPromises()
expect(text(document.body)).toContain(
"You're offline. Changes will be saved when you reconnect.",
)
expect(chip()).toBe('Waiting for connection')
expect(saveButton()!.disabled).toBe(true)
expect(nameInput()!.value).toBe('Events and talks')
setOnline(true)
await flushPromises()
expect(text(document.body)).not.toContain("You're offline.")
expect(saveButton()!.disabled).toBe(false)
expect(puts).toHaveLength(0)
})
it('disables Apply changes while the review is open offline and keeps the edits', async () => {
await mountAt('/schema/events')
await setMax('2000')
await click(saveButton())
setOnline(false)
await flushPromises()
expect((button('Apply changes') as HTMLButtonElement).disabled).toBe(true)
expect(badgesOf('Capacity')).toContain('Changed')
setOnline(true)
await flushPromises()
expect((button('Apply changes') as HTMLButtonElement).disabled).toBe(false)
})
})
describe('Accessibility of the edit mode', () => {
it('keeps Undo a reachable button', async () => {
await mountAt('/schema/events')
await selectRow('Description')
await click(byLabel('Remove'))
const undo = undoButtons()[0]!
expect(undo.tagName).toBe('BUTTON')
expect(undo.tabIndex).toBe(0)
expect(undo.getAttribute('type')).toBe('button')
})
it('ties the lock icons to a text or a help', async () => {
await mountAt('/schema/events')
expect(keyInput()!.readOnly).toBe(true)
expect(keyInput()!.closest('[data-orientation]')).not.toBeNull()
await selectRow('Starts on')
expect(text(el('[data-testid="field-inspector"]'))).toContain(
"Used in the API, so it can't change.",
)
expect(text(el('[data-testid="field-inspector"]'))).toContain("Can't change after saving")
})
it('names the dialog and keeps the focus inside it', async () => {
await mountAt('/schema/events')
await setMax('2000')
await click(saveButton())
const open = dialog()!
expect(open.getAttribute('role')).toBe('alertdialog')
expect(open.getAttribute('aria-labelledby')).not.toBeNull()
expect(open.contains(document.activeElement)).toBe(true)
})
})
@@ -67,8 +67,8 @@ describe('Reaching the builder', () => {
expect(nameInput()).not.toBeNull()
})
it.each(['/schema/events', '/schema/unknown'])('still says not found at %s', async (path) => {
await mountAt(path)
it('still says not found at /schema/unknown', async () => {
await mountAt('/schema/unknown')
expect(text(main())).toContain(NOT_FOUND_TITLE)
expect(nameInput()).toBeNull()
+11 -20
View File
@@ -44,7 +44,7 @@ describe('Schema list: built-in rows are links', () => {
it('links each built-in row to its view, named after the type, with a chevron', async () => {
await mountAt('/schema')
const links = [...document.body.querySelectorAll('a[data-testid="schema-row"]')]
const links = [...document.body.querySelectorAll('a[data-testid="schema-row"]')].slice(3)
expect(links.map((link) => link.getAttribute('href'))).toEqual(
builtinTypes.map((type) => `/schema/${type.key}`),
)
@@ -52,11 +52,6 @@ describe('Schema list: built-in rows are links', () => {
builtinTypes.map((type) => type.name),
)
expect(links.every((link) => link.querySelectorAll('svg').length === 3)).toBe(true)
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
expect(rows.slice(0, 3).every((row) => row.tagName !== 'A')).toBe(true)
expect(
rows.slice(0, 3).every((row) => row.querySelector('[class*="chevron-right"]') === null),
).toBe(true)
expect(text(links[0])).toContain('Managed by RenewCMS')
})
@@ -64,17 +59,16 @@ describe('Schema list: built-in rows are links', () => {
await mountAt('/schema', { width: MOBILE_WIDTH })
const cards = [...document.body.querySelectorAll('[data-testid="schema-card"]')]
const links = cards.filter((card) => card.tagName === 'A')
const links = cards.filter((card) => card.tagName === 'A').slice(3)
expect(links).toHaveLength(5)
expect(text(links[0])).toBe('Articles Built-in articles · 5 fields')
expect(links[0]!.getAttribute('href')).toBe('/schema/articles')
expect(links[0]!.querySelector('svg:last-of-type')).not.toBeNull()
expect(cards.slice(0, 3).every((card) => card.tagName !== 'A')).toBe(true)
})
it('opens the type without loading the built-in types a second time', async () => {
await mountAt('/schema')
;(document.body.querySelector('a[data-testid="schema-row"]') as HTMLElement).click()
;(document.body.querySelectorAll('a[data-testid="schema-row"]')[3] as HTMLElement).click()
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/articles'))
@@ -178,18 +172,15 @@ describe('Schema built-in view: loading', () => {
})
describe('Schema built-in view: unknown key', () => {
it.each(['/schema/unknown', '/schema/events'])(
'shows the not-found page at %s without redirecting',
async (path) => {
await mountAt(path)
it('shows the not-found page at /schema/unknown without redirecting', async () => {
await mountAt('/schema/unknown')
expect(text(main())).toContain(NOT_FOUND_TITLE)
expect(text(main())).toContain(NOT_FOUND_TEXT)
expect(router.currentRoute.value.path).toBe(path)
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
expect(main().querySelector('button')).toBeNull()
},
)
expect(text(main())).toContain(NOT_FOUND_TITLE)
expect(text(main())).toContain(NOT_FOUND_TEXT)
expect(router.currentRoute.value.path).toBe('/schema/unknown')
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
expect(main().querySelector('button')).toBeNull()
})
})
describe('Schema built-in view: error', () => {
+34 -7
View File
@@ -83,21 +83,44 @@ describe('Schema list: populated', () => {
expect(text(main())).toContain('3 content types · 5 built-in')
})
it('does not make rows links', async () => {
it('makes every row a link to its type, custom rows included', async () => {
await mountAt('/schema')
expect(document.body.querySelectorAll('[data-testid="schema-row"] a')).toHaveLength(0)
await document.body.querySelector<HTMLElement>('[data-testid="schema-row"]')!.click()
const links = [...document.body.querySelectorAll('a[data-testid="schema-row"]')]
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/schema/events',
'/schema/recipes',
'/schema/press-mentions',
'/schema/articles',
'/schema/authors',
'/schema/sites',
'/schema/media',
'/schema/users',
])
expect(links[0]!.getAttribute('aria-label')).toBe('Events')
})
it('opens the editor when the Events row is activated', async () => {
await mountAt('/schema')
document.body.querySelector<HTMLElement>('a[data-testid="schema-row"]')!.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/events'))
})
it('turns into cards below 640 px with the same content', async () => {
await mountAt('/schema', { width: 390 })
const cards = [...document.body.querySelectorAll('[data-testid="schema-card"]')].map(text)
const elements = [...document.body.querySelectorAll('[data-testid="schema-card"]')]
const cards = elements.map(text)
expect(cards).toHaveLength(8)
expect(cards[0]).toBe('Events events · 7 fields · Updated 15 Oct 2026')
expect(elements.slice(0, 3).map((card) => card.getAttribute('href'))).toEqual([
'/schema/events',
'/schema/recipes',
'/schema/press-mentions',
])
expect(cards[3]).toBe('Articles Built-in articles · 5 fields')
expect(document.body.querySelector('[data-testid="schema-row"]')).toBeNull()
})
@@ -348,8 +371,12 @@ describe('Schema route', () => {
const to = router.resolve('/schema')
expect(to.meta).toMatchObject({ auth: 'verified', title: 'Schema' })
expect(resolveAuthRedirect(to, session)).toMatchObject({ path: '/login' })
expect(resolveAuthRedirect(to, session)).toMatchObject({
path: '/login',
})
session.session = { verified: false, displayName: '' }
expect(resolveAuthRedirect(to, session)).toEqual({ path: '/register/pending' })
expect(resolveAuthRedirect(to, session)).toEqual({
path: '/register/pending',
})
})
})
+40
View File
@@ -105,3 +105,43 @@ export async function buildEvents() {
await addChoice('Workshop')
await addChoice('Meetup')
}
export const rowOf = (label: string) =>
rows().find((row) => text(row.querySelector('[data-testid="field-row-label"]')) === label)
export const badgesOf = (label: string) =>
text(rowOf(label)?.querySelector('[data-testid="field-row-badges"]'))
export const undoRows = () => all('[data-testid="undo-row"]').map(text)
export const undoButtons = () => all<HTMLButtonElement>('[data-testid="undo-action"]')
export const notice = () => text(el('[data-testid="entries-notice"]'))
export const dialog = () => el('[role="alertdialog"]')
export const inspectorTitle = () => text(el('[data-testid="inspector-pane"] h2'))
export async function setMax(value: string, row = 'Capacity') {
await selectRow(row)
await typeInto(limit('max'), value)
}
export async function removeSelected() {
await click(byLabel('Remove'))
}
export async function chooseDefault(name: string) {
await press(el('button[role="combobox"]'), 'ArrowDown')
await press(
all('[role="option"]').find((option) => text(option) === name),
'Enter',
)
}
export async function makeUnsavedChanges() {
await setMax('2000')
await addField('Speaker')
await addField('Room')
await pickTile('Choice')
await toggle('Required')
await addChoice('Main hall')
await addChoice('Studio')
await chooseDefault('Main hall')
await selectRow('Description')
await removeSelected()
}
+30 -2
View File
@@ -11,6 +11,9 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
export type Responder = () => unknown
@@ -20,7 +23,7 @@ export const customTypes = [
key: 'events',
name: 'Events',
icon: 'files',
fields: new Array(7),
fields: eventsFields,
updated_at: '2026-10-15T09:30:00Z',
},
{
@@ -28,7 +31,7 @@ export const customTypes = [
key: 'recipes',
name: 'Recipes',
icon: null,
fields: new Array(4),
fields: recipesFields,
updated_at: '2026-10-02T09:30:00Z',
},
{
@@ -117,9 +120,17 @@ export interface PostedRequest {
body: Record<string, unknown>
}
export const entryCounts: Record<string, number> = { events: 37, recipes: 0 }
export function entryPage(total: number) {
return { items: [], total, page: 1, size: 1 }
}
export interface SchemaMount {
role?: string
create?: (body: PostedRequest['body']) => unknown
update?: (body: PostedRequest['body'], key: string) => unknown
entries?: (key: string) => unknown
width?: number
content?: Responder
builtin?: Responder
@@ -130,10 +141,17 @@ export interface SchemaMount {
let wrapper: VueWrapper | undefined
export const calls: string[] = []
export const posts: PostedRequest[] = []
export const puts: PostedRequest[] = []
export function updatedType(key: string, body: Record<string, unknown>) {
const base = customTypes.find((type) => type.key === key)!
return { ...base, ...body, key, updated_at: '2026-10-19T11:02:00Z' }
}
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
calls.length = 0
posts.length = 0
puts.length = 0
localStorage.clear()
setViewportWidth(options.width ?? DESKTOP_WIDTH)
const pinia = createPinia()
@@ -155,6 +173,12 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
}
session.status = 'ready'
const request = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const body = JSON.parse(String(init.body)) as PostedRequest['body']
const key = url.split('/').pop()!
puts.push({ url, body })
return (options.update ?? ((sent, target) => updatedType(target, sent)))(body, key)
}
if (init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as PostedRequest['body']
posts.push({ url, body })
@@ -164,6 +188,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
if (url === '/v1/content-type') {
return (options.content ?? (() => [...customTypes]))()
}
const counted = url.match(/^\/v1\/content-type\/([^/]+)\/entry\?page=1&size=5$/)
if (counted) {
return (options.entries ?? ((key) => entryPage(entryCounts[key] ?? 0)))(counted[1]!)
}
if (url === '/v1/builtin-type') {
return (options.builtin ?? (() => builtinTypes))()
}