feat: schema builder's create flow

This commit is contained in:
Florian Sylvain committed 2026-10-20 00:29:01 +02:00
1 parent f514146fed
commit 083cc9e23e
71 files changed
+7223 -61

No files matched your search

+287
View File
@@ -0,0 +1,287 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } 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 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 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>
<DashboardShell
full-bleed
:mobile-title="mobileTitle"
:back-to="SCHEMA_PATH"
:back-label="t('Schema')"
:offline-message="t(OFFLINE_COPY)"
offline-icon="i-lucide-wifi-off"
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</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>
</DashboardShell>
</template>
+4 -5
View File
@@ -11,8 +11,7 @@ import SchemaForbidden from '@/components/SchemaForbidden.vue'
import SchemaTable from '@/components/SchemaTable.vue'
import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
import { useSchemaGate } from '@/composables/useSchemaGate'
const LIST_PATH = '/schema'
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
const route = useRoute()
const router = useRouter()
@@ -23,12 +22,12 @@ 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 breadcrumb = computed(() => [
{ label: t('Schema'), to: LIST_PATH },
{ label: t('Schema'), to: SCHEMA_PATH },
...(type.value ? [{ label: type.value.name }] : []),
])
function goHome() {
router.push('/home')
router.push(HOME_PATH)
}
</script>
@@ -37,7 +36,7 @@ function goHome() {
compact-padding
:full-bleed="forbidden || (sessionReady && !loading && !failed && !type)"
:mobile-title="mobileTitle"
:back-to="LIST_PATH"
:back-to="SCHEMA_PATH"
:back-label="t('Schema')"
>
<template #breadcrumb>
@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { button, mountAt, posts, text, unmountAll } from './schemaTestKit'
import {
all,
byLabel,
chip,
click,
discardButton,
el,
keyField,
keyInput,
main,
nameInput,
rows,
saveButton,
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 KEY_MESSAGE = 'Use 2 to 40 lowercase letters, digits or hyphens.'
function errorsOf(container: Element | null) {
return [...(container?.querySelectorAll('[role="alert"]') ?? [])].map(text)
}
function nameField() {
return nameInput()!.closest<HTMLElement>('[data-orientation]')!
}
async function unlockKey() {
await click(keyField().querySelector('button[aria-label="Edit key"]'))
}
describe('Type-level rules (design states 6 and 14)', () => {
it.each([
['', 'Give the content type a name.'],
[' ', 'Give the content type a name.'],
])(
'shows the name message for %j with an alert icon and sends nothing',
async (value, message) => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await typeInto(nameInput(), value)
await click(saveButton())
expect(errorsOf(nameField())).toEqual([message])
expect(nameField().querySelector('[role="alert"] svg')).not.toBeNull()
expect(posts).toHaveLength(0)
},
)
it.each([
['e', KEY_MESSAGE],
['Events', KEY_MESSAGE],
['my_type', KEY_MESSAGE],
['-events', KEY_MESSAGE],
['events--2', KEY_MESSAGE],
['new', 'The key "new" is reserved. Choose a different key.'],
])('shows the key message for %j and sends nothing', async (value, message) => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await unlockKey()
await typeInto(keyInput(), value)
await click(saveButton())
expect(errorsOf(keyField())).toEqual([message])
expect(keyField().querySelector('[role="alert"] svg')).not.toBeNull()
expect(posts).toHaveLength(0)
})
it.each(['ab', 'press-mentions', '2026-awards', 'a'.repeat(40)])(
'sends the accepted key %j',
async (key) => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'X')
await unlockKey()
await typeInto(keyInput(), key)
await click(saveButton())
expect(posts).toHaveLength(1)
expect(posts[0]!.body.key).toBe(key)
},
)
})
describe('Type-level errors together (design state 6)', () => {
async function reachState6() {
await mountAt('/schema/new')
await typeInto(nameInput(), 'x')
await unlockKey()
await typeInto(keyInput(), 'e')
await typeInto(nameInput(), '')
await click(byLabel('Remove'))
}
it('shows the three messages, the empty list, the chip, and disables Save only', async () => {
await reachState6()
expect(errorsOf(nameField())).toEqual(['Give the content type a name.'])
expect(errorsOf(keyField())).toEqual([KEY_MESSAGE])
const empty = el('[data-testid="field-empty"]')!
expect(text(empty)).toContain('No fields yet')
expect(text(empty)).toContain('A content type needs at least one field.')
expect(empty.querySelector('.text-error')).not.toBeNull()
expect(chip()).toBe('3 problems to fix')
expect(saveButton()!.disabled).toBe(true)
expect(discardButton()!.disabled).toBe(false)
expect(text(el('[data-testid="inspector-pane"]'))).toContain('Field settings')
expect(text(el('[data-testid="inspector-pane"]'))).toContain(
'Add a field, then choose its type and rules here.',
)
})
it('reveals every error at once when Save is activated, then enables Save once fixed', async () => {
await mountAt('/schema/new')
await click(byLabel('Remove'))
await click(button('Add field'))
expect(chip()).toBe('3 problems to fix')
expect(errorsOf(nameField())).toEqual([])
expect(errorsOf(keyField())).toEqual([])
expect(saveButton()!.disabled).toBe(false)
expect(saveButton()!.getAttribute('aria-disabled')).toBe('true')
await click(saveButton())
expect(chip()).toBe('3 problems to fix')
expect(errorsOf(nameField())).toEqual(['Give the content type a name.'])
expect(saveButton()!.disabled).toBe(true)
expect(posts).toHaveLength(0)
await typeInto(nameInput(), 'Events')
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'Title')
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
})
})
describe('When errors are shown', () => {
it('shows none on a pristine form', async () => {
await mountAt('/schema/new')
expect(all('[role="alert"]')).toHaveLength(0)
expect(all('[aria-invalid="true"]')).toHaveLength(0)
})
it('shows an error for the input being edited and for no other', async () => {
await mountAt('/schema/new')
await click(button('Add field'))
await typeInto(nameInput(), 'Events')
await typeInto(nameInput(), '')
expect(errorsOf(main())).toEqual(['Give the content type a name.'])
})
it('ties each error to its input with aria-invalid and aria-describedby', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'x')
await typeInto(nameInput(), '')
const input = nameInput()!
expect(input.getAttribute('aria-invalid')).toBe('true')
const describedBy = input.getAttribute('aria-describedby')!
expect(describedBy).not.toBe('')
expect(text(document.getElementById(describedBy.split(' ')[0]!))).toBe(
'Give the content type a name.',
)
})
it('marks a row with an error with an alert icon, not only a border', async () => {
await mountAt('/schema/new')
await click(button('Add field'))
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'x')
await typeInto(el<HTMLInputElement>('input[name="label"]'), '')
const invalid = rows().filter((row) => row.querySelector('[data-testid="field-row-alert"]'))
expect(invalid).toHaveLength(1)
expect(invalid[0]!.className).toContain('border-error')
})
})
@@ -0,0 +1,727 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { button, failWithBody, mountAt, posts, text, unmountAll } from './schemaTestKit'
import {
addChoice,
addChoiceInput,
addField,
all,
byLabel,
choiceValues,
chip,
click,
defaultInput,
el,
fieldKeyInput,
inspector,
labelInput,
limit,
nameInput,
pickTile,
press,
rowLabels,
rows,
saveButton,
selectRow,
switchOf,
tile,
toggle,
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(unmountAll)
function fieldRoot(input: HTMLElement | null) {
return input!.closest<HTMLElement>('[data-orientation]')!
}
function errorsIn(container: Element | null) {
return [...(container?.querySelectorAll('[role="alert"]') ?? [])].map(text)
}
async function unlockFieldKey() {
await click(fieldRoot(fieldKeyInput()).querySelector('button[aria-label="Edit key"]'))
}
const KEY_RULE = 'Start with a letter. Use lowercase letters, digits and underscores.'
describe('Field list rows', () => {
it('shows a grip, the type icon box, the label and the mono key of each row', async () => {
await mountAt('/schema/new')
await addField('Starts on')
await pickTile('Date')
await addField('Online')
await pickTile('Yes or no')
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Online'])
const second = rows()[1]!
expect(second.querySelector('[data-grip]')!.getAttribute('aria-label')).toBe(
'Move Starts on',
)
expect(second.querySelector('[data-field-row] svg')).not.toBeNull()
expect(text(second.querySelector('[data-testid="field-row-key"]'))).toBe('starts_on')
})
it('shows the badges on an unselected row and the actions on the selected one only', async () => {
await mountAt('/schema/new')
await toggle('Required')
await toggle('Unique values')
await addField('Second')
const first = rows()[0]!
expect(text(first.querySelector('[data-testid="field-row-badges"]'))).toBe(
'New Required Unique',
)
expect(first.querySelector('[data-action]')).toBeNull()
const selected = rows()[1]!
expect(selected.className).toContain('bg-(--rc-sand)')
expect(
[...selected.querySelectorAll('[data-action]')].map((node) =>
node.getAttribute('aria-label'),
),
).toEqual(['Move up', 'Move down', 'Remove'])
})
it('reads Untitled field and a dash while a field has no label or key', async () => {
await mountAt('/schema/new')
await click(button('Add field'))
const added = rows()[1]!
expect(text(added.querySelector('[data-testid="field-row-label"]'))).toBe('Untitled field')
expect(text(added.querySelector('[data-testid="field-row-key"]'))).toBe('–')
})
})
describe('Adding a field', () => {
it('appends a selected Text row, focuses the label and counts 2 of 50', async () => {
await mountAt('/schema/new')
await click(button('Add field'))
expect(rows()).toHaveLength(2)
expect(rows()[1]!.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe(
'true',
)
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
'Text',
)
expect(labelInput()!.value).toBe('')
expect(document.activeElement).toBe(labelInput())
expect(text(el('[data-testid="field-count"]'))).toBe('2 of 50')
})
it('disables Add field at 50 fields', async () => {
await mountAt('/schema/new')
for (let count = 1; count < 50; count++) {
await click(button('Add field'))
}
expect(text(el('[data-testid="field-count"]'))).toBe('50 of 50')
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
})
})
describe('Field key from the label', () => {
it.each([
['Title', 'title_2'],
['Starts on', 'starts_on'],
['Éléments', 'elements'],
['Ends on (date)', 'ends_on_date'],
['2nd room', 'nd_room'],
['a'.repeat(60), 'a'.repeat(40)],
])('label %j gives the key %j with the Auto badge', async (label, key) => {
await mountAt('/schema/new')
await addField(label)
expect(fieldKeyInput()!.value).toBe(key)
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(text(fieldRoot(fieldKeyInput()))).toContain('Auto')
})
})
describe('Field key rules (design state 7)', () => {
it.each(['Title', '1st', 'a-b', 'a b', ''])('shows the key message for %j', async (key) => {
await mountAt('/schema/new')
await addField('Thing')
await unlockFieldKey()
await typeInto(fieldKeyInput(), key)
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([KEY_RULE])
})
it.each(['starts_on', 'a', 'a'.repeat(40)])('accepts %j', async (key) => {
await mountAt('/schema/new')
await addField('Thing')
await unlockFieldKey()
await typeInto(fieldKeyInput(), key)
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([])
expect(fieldKeyInput()!.maxLength).toBe(40)
})
it('flags a key already used by an earlier field under the second field', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await addField('Second')
await unlockFieldKey()
await typeInto(fieldKeyInput(), 'title')
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([
'Another field already uses this key.',
])
expect(chip()).toBe('1 problem to fix')
})
it('shows one error with the alert icon, the chip and a disabled Save for a hyphenated key', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await addField('Start date')
await unlockFieldKey()
await typeInto(fieldKeyInput(), 'start-date')
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([KEY_RULE])
expect(fieldRoot(fieldKeyInput()).querySelector('[role="alert"] svg')).not.toBeNull()
expect(chip()).toBe('1 problem to fix')
expect(saveButton()!.disabled).toBe(true)
})
})
const TILES = [
['Text', 'Short text, like a title or a name', 'type'],
['Rich text', 'Long text with formatting, written in Markdown', 'pilcrow'],
['Number', 'Whole or decimal numbers', 'hash'],
['Yes or no', 'An on or off switch', 'toggle-left'],
['Date', 'A calendar day, without a time', 'calendar'],
['Choice', 'One option from a list you define', 'list'],
] as const
describe('Type tiles', () => {
it('shows six radio tiles with an icon, a name and a description', async () => {
await mountAt('/schema/new')
expect(el('[role="radiogroup"]')).not.toBeNull()
for (const [name, description, icon] of TILES) {
const radio = tile(name)
const label = radio.closest('label')!
expect(text(label)).toBe(`${name} ${description}`)
expect(label.querySelector('svg')).not.toBeNull()
expect(icon).not.toBe('')
}
})
it('marks the selected tile with the sand background', async () => {
await mountAt('/schema/new')
await pickTile('Date')
const label = tile('Date').closest('label')!
expect(tile('Date').getAttribute('aria-checked')).toBe('true')
expect(label.className).toContain('has-data-[state=checked]:bg-(--rc-sand)')
})
})
describe('The inspector follows the type', () => {
it.each([
[
'Text',
'Shortest',
'Longest',
true,
'characters',
'text',
'Counted in characters. Entries show a counter.',
],
[
'Rich text',
'Shortest',
'Longest',
false,
'characters',
'textarea',
'Counted in characters. Entries show a counter.',
],
[
'Number',
'Lowest value',
'Highest value',
true,
'',
'number',
'Entries outside the range are refused.',
],
[
'Date',
'Earliest date',
'Latest date',
true,
'',
'date',
'Entries outside the range are refused.',
],
])('%s', async (name, low, high, unique, suffix, control, help) => {
await mountAt('/schema/new')
await pickTile(name)
const copy = text(inspector())
expect(copy).toContain(low)
expect(copy).toContain(high)
expect(copy).toContain(help)
expect(copy.includes('characters')).toBe(suffix === 'characters')
expect(switchOf('Unique values').disabled).toBe(!unique)
expect(copy.includes('Not available for rich text.')).toBe(name === 'Rich text')
const field =
control === 'textarea'
? el<HTMLTextAreaElement>('textarea')
: (defaultInput() as HTMLInputElement)
expect(field!.placeholder).toBe('No default')
if (control !== 'textarea') {
expect(defaultInput()!.type).toBe(control)
}
})
it('shows no limits and a disabled Unique switch for Yes or no', async () => {
await mountAt('/schema/new')
await pickTile('Yes or no')
const copy = text(inspector())
expect(copy).toContain('Yes or no fields have no limits.')
expect(copy).toContain('Not available for yes or no fields.')
expect(switchOf('Unique values').disabled).toBe(true)
expect(copy).toContain('On by default')
expect(copy).toContain('New entries start with this switch off when the field is left out.')
expect(limit('min')).toBeNull()
expect(defaultInput()).toBeNull()
})
it('shows the choice list and no limits for Choice', async () => {
await mountAt('/schema/new')
await pickTile('Choice')
const copy = text(inspector())
expect(copy).toContain('Limits come from the choices below.')
expect(copy).toContain('Choices')
expect(el('[data-testid="choice-list"]')).not.toBeNull()
expect(switchOf('Unique values').disabled).toBe(false)
expect(limit('min')).toBeNull()
expect(el('button[role="combobox"]')).not.toBeNull()
})
})
describe('Changing the type of a new field', () => {
it('keeps the label, key and Required, resets the rest, and starts a Choice with no choice', async () => {
await mountAt('/schema/new')
await addField('Capacity')
await pickTile('Number')
await toggle('Required')
await toggle('Unique values')
await typeInto(limit('min'), '1')
await typeInto(limit('max'), '5000')
await typeInto(defaultInput(), '20')
expect(defaultInput()!.disabled).toBe(true)
await pickTile('Yes or no')
expect(labelInput()!.value).toBe('Capacity')
expect(fieldKeyInput()!.value).toBe('capacity')
expect(switchOf('Required').getAttribute('aria-checked')).toBe('true')
expect(switchOf('Unique values').getAttribute('aria-checked')).toBe('false')
await pickTile('Choice')
expect(choiceValues()).toEqual([])
expect(switchOf('Required').getAttribute('aria-checked')).toBe('true')
await pickTile('Number')
expect(limit('min')!.value).toBe('')
expect(limit('max')!.value).toBe('')
expect(defaultInput()!.value).toBe('')
})
})
describe('Field rules', () => {
async function ready(type: string) {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await addField('Thing')
await pickTile(type)
}
it('flags an empty label or spaces', async () => {
await ready('Text')
await typeInto(labelInput(), ' ')
expect(errorsIn(fieldRoot(labelInput()))).toEqual(['Give the field a label.'])
})
it.each([
['Number', { min: '6000', max: '5000' }, "Lowest value can't be above the highest value."],
['Text', { min: '10', max: '3' }, "Shortest can't be longer than longest."],
[
'Date',
{ min: '2027-12-31', max: '2026-01-01' },
"Earliest date can't be after the latest date.",
],
['Text', { min: '2.5' }, 'Use a whole number, 0 or more.'],
['Text', { min: '-1' }, 'Use a whole number, 0 or more.'],
])('flags %s limits %j under the limits', async (type, values, message) => {
await ready(type)
for (const [which, value] of Object.entries(values)) {
await typeInto(limit(which as 'min' | 'max'), value)
}
expect(errorsIn(fieldRoot(limit('min')))).toEqual([message])
expect(chip()).toBe('1 problem to fix')
})
it.each([
['Number', { min: '1', max: '5000', default: '0' }],
['Text', { max: '3', default: 'abcd' }],
])('flags a default outside the limits for %s', async (type, values) => {
await ready(type)
for (const [which, value] of Object.entries(values)) {
await typeInto(
which === 'default' ? defaultInput() : limit(which as 'min' | 'max'),
value,
)
}
expect(errorsIn(fieldRoot(defaultInput()))).toEqual([
'The default value is outside the limits.',
])
})
it.each([
['Number', { min: '1', max: '5000' }],
['Number', { min: '5', max: '5' }],
['Text', { min: '0' }],
])('accepts %s limits %j', async (type, values) => {
await ready(type)
for (const [which, value] of Object.entries(values)) {
await typeInto(limit(which as 'min' | 'max'), value)
}
expect(errorsIn(inspector())).toEqual([])
expect(chip()).toBe('Unsaved changes')
})
it('flags a Choice field with no choice under the choice list', async () => {
await ready('Choice')
await addChoice('Talk')
await click(byLabel('Remove Talk'))
expect(errorsIn(el('[data-testid="choice-list"]'))).toEqual(['Add at least one choice.'])
})
})
describe('Unique and default', () => {
it('disables the default and drops it from the body while Unique is on, then brings it back', async () => {
await mountAt('/schema/new', { create: failWithBody(500, 'boom') })
await typeInto(nameInput(), 'Events')
await typeInto(defaultInput(), 'Draft')
await toggle('Unique values')
expect(defaultInput()!.disabled).toBe(true)
expect(text(inspector())).toContain("A field with unique values can't have a default.")
await click(saveButton())
expect(posts[0]!.body.fields).toMatchObject([{ unique: true, default: null }])
await toggle('Unique values')
expect(defaultInput()!.disabled).toBe(false)
expect(defaultInput()!.value).toBe('Draft')
})
})
describe('Choice list editor (design state 8)', () => {
async function withChoices() {
await mountAt('/schema/new')
await pickTile('Choice')
await addChoice('Talk')
await addChoice('Workshop')
await addChoice('Meetup')
}
it('shows the required marker, the count, the rows, the add input and the help', async () => {
await withChoices()
const list = el('[data-testid="choice-list"]')!
expect(text(list)).toContain('Choices *')
expect(text(el('[data-testid="choice-count"]'))).toBe('3 of 50')
expect(choiceValues()).toEqual(['Talk', 'Workshop', 'Meetup'])
expect(all<HTMLInputElement>('[data-testid="choice-row"] input')[0]!.placeholder).toBe(
'Choice',
)
expect(addChoiceInput()!.placeholder).toBe('Add a choice')
expect(button('Add')).toBeInstanceOf(HTMLButtonElement)
expect(text(list)).toContain(
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.',
)
})
it('appends with the button or Enter, empties the input and keeps the focus', async () => {
await withChoices()
await typeInto(addChoiceInput(), 'Panel')
await click(button('Add'))
expect(choiceValues()).toEqual(['Talk', 'Workshop', 'Meetup', 'Panel'])
expect(addChoiceInput()!.value).toBe('')
expect(document.activeElement).toBe(addChoiceInput())
await typeInto(addChoiceInput(), 'Social')
await press(addChoiceInput(), 'Enter')
expect(choiceValues().at(-1)).toBe('Social')
expect(document.activeElement).toBe(addChoiceInput())
})
it('removes a choice', async () => {
await withChoices()
await click(byLabel('Remove Workshop'))
expect(choiceValues()).toEqual(['Talk', 'Meetup'])
})
it('lists the choices in the default select and clears the default on remove or rename', async () => {
await withChoices()
const openSelect = () => press(el('button[role="combobox"]'), 'ArrowDown')
await openSelect()
const options = all('[role="option"]').map(text)
expect(options).toEqual(['None', 'Talk', 'Workshop', 'Meetup'])
await press(
all('[role="option"]').find((option) => text(option) === 'Workshop'),
'Enter',
)
expect(text(el('button[role="combobox"]'))).toContain('Workshop')
await typeInto(all<HTMLInputElement>('[data-testid="choice-row"] input')[1], 'Workshops')
expect(text(el('button[role="combobox"]'))).toContain('None')
await openSelect()
await press(
all('[role="option"]').find((option) => text(option) === 'Meetup'),
'Enter',
)
await click(byLabel('Remove Meetup'))
expect(text(el('button[role="combobox"]'))).toContain('None')
})
it('flags a blank row and a duplicate row with an alert icon, as one problem, and caps at 50', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await pickTile('Choice')
for (const value of ['Talk', 'Workshop', 'Meetup', 'x', 'y']) {
await addChoice(value)
}
const inputs = () => all<HTMLInputElement>('[data-testid="choice-row"] input')
await typeInto(inputs()[3], ' ')
await typeInto(inputs()[4], ' Talk ')
const rowsWithErrors = all('[data-testid="choice-row"]').map((row) => errorsIn(row))
expect(rowsWithErrors[3]).toEqual(["Choices can't be blank."])
expect(rowsWithErrors[4]).toEqual(['Two choices are the same.'])
expect(all('[data-testid="choice-row"] [role="alert"] svg')).toHaveLength(2)
expect(chip()).toBe('1 problem to fix')
expect(inputs()[0]!.getAttribute('aria-invalid')).not.toBe('true')
expect(inputs()[4]!.getAttribute('aria-invalid')).toBe('true')
})
it('treats choices as different when they differ by case', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await pickTile('Choice')
await addChoice('Talk')
await addChoice('talk')
expect(errorsIn(el('[data-testid="choice-list"]'))).toEqual([])
})
it('disables Add at 50 choices', async () => {
await mountAt('/schema/new')
await pickTile('Choice')
for (let index = 0; index < 50; index++) {
await addChoice(`C${index}`)
}
expect(text(el('[data-testid="choice-count"]'))).toBe('50 of 50')
await typeInto(addChoiceInput(), 'more')
expect((button('Add') as HTMLButtonElement).disabled).toBe(true)
})
})
describe('Reordering choices', () => {
it('moves with the keyboard grip and announces each step', async () => {
await mountAt('/schema/new')
await pickTile('Choice')
for (const value of ['Talk', 'Workshop', 'Meetup']) {
await addChoice(value)
}
const grip = () => byLabel('Move Workshop')!
await press(grip(), ' ')
expect(text(el('[data-testid="choice-live"]'))).toBe(
'Moving Workshop. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel.',
)
await press(grip(), 'ArrowUp')
expect(text(el('[data-testid="choice-live"]'))).toContain(
'Moving Workshop. Position 1 of 3.',
)
await press(grip(), ' ')
expect(choiceValues()).toEqual(['Workshop', 'Talk', 'Meetup'])
expect(text(el('[data-testid="choice-live"]'))).toBe('Workshop dropped. Position 1 of 3.')
})
})
describe('Removing a field', () => {
it('selects the next row, then the previous at the end, and counts one less', async () => {
await mountAt('/schema/new')
await addField('Starts on')
await addField('Ends on')
await selectRow('Starts on')
await click(byLabel('Remove'))
expect(rowLabels()).toEqual(['Title', 'Ends on'])
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Ends on')
expect(text(el('[data-testid="field-count"]'))).toBe('2 of 50')
expect(text(document.body)).not.toContain('Undo')
await click(byLabel('Remove'))
expect(rowLabels()).toEqual(['Title'])
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
})
it('shows the empty state and Field settings when the list is empty', async () => {
await mountAt('/schema/new')
await click(byLabel('Remove'))
expect(text(el('[data-testid="field-empty"]'))).toContain('No fields yet')
expect(text(el('[data-testid="inspector-pane"]'))).toContain('Field settings')
expect(text(el('[data-testid="field-count"]'))).toBe('0 of 50')
})
})
describe('Moving fields with the buttons', () => {
async function three() {
await mountAt('/schema/new')
await addField('Starts on')
await addField('Ends on')
await selectRow('Starts on')
}
it.each([
['Move up', ['Starts on', 'Title', 'Ends on']],
['Move down', ['Title', 'Ends on', 'Starts on']],
])('%s reorders and keeps the selection', async (label, order) => {
await three()
await click(byLabel(label))
expect(rowLabels()).toEqual(order)
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Starts on')
})
it('keeps the focus on the pressed button while it stays enabled', async () => {
await three()
await addField('Fourth')
await selectRow('Starts on')
await click(byLabel('Move down'))
expect(rowLabels()).toEqual(['Title', 'Ends on', 'Starts on', 'Fourth'])
expect(document.activeElement).toBe(byLabel('Move down'))
})
it('moves the focus to the other button when the pressed one becomes disabled', async () => {
await three()
await click(byLabel('Move up'))
expect((byLabel('Move up') as HTMLButtonElement).disabled).toBe(true)
expect(document.activeElement).toBe(byLabel('Move down'))
})
it.each([
['Title', 'Move up'],
['Ends on', 'Move down'],
])('disables the end button for %s: %s', async (row, label) => {
await three()
await selectRow(row)
expect((byLabel(label) as HTMLButtonElement).disabled).toBe(true)
})
})
describe('Keyboard reorder (design state 9)', () => {
async function three() {
await mountAt('/schema/new')
await addField('Starts on')
await addField('Ends on')
}
const grip = (label: string) => byLabel(`Move ${label}`)!
it('picks up with Space, moves with Up, drops with Space and announces each step', async () => {
await three()
await press(grip('Starts on'), ' ')
expect(text(el('[data-testid="reorder-live"]'))).toBe(
'Moving Starts on. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel.',
)
expect(grip('Starts on').closest('li')!.className).toContain('outline-primary')
expect(el('[data-testid="reorder-live"]')!.getAttribute('aria-live')).toBe('polite')
await press(grip('Starts on'), 'ArrowUp')
expect(rowLabels()).toEqual(['Starts on', 'Title', 'Ends on'])
expect(text(el('[data-testid="reorder-live"]'))).toContain(
'Moving Starts on. Position 1 of 3.',
)
await press(grip('Starts on'), ' ')
expect(rowLabels()).toEqual(['Starts on', 'Title', 'Ends on'])
expect(text(el('[data-testid="reorder-live"]'))).toBe('Starts on dropped. Position 1 of 3.')
})
it('restores the order on Esc', async () => {
await three()
await press(grip('Starts on'), ' ')
await press(grip('Starts on'), 'ArrowUp')
await press(grip('Starts on'), 'Escape')
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Ends on'])
expect(text(el('[data-testid="reorder-live"]'))).toContain('Reorder cancelled.')
})
it('has no pointer drag on rows', async () => {
await three()
for (const row of rows()) {
expect(row.getAttribute('draggable')).toBeNull()
expect(row.querySelector('[draggable]')).toBeNull()
}
})
})
@@ -0,0 +1,156 @@
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 { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { button, mountAt, text, unmountAll } from './schemaTestKit'
import {
addField,
all,
byLabel,
click,
el,
nameInput,
pickTile,
press,
typeInto,
} from './builderKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
const writeText = vi.fn()
beforeEach(() => {
writeText.mockReset().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
})
afterEach(async () => {
await unmountAll()
})
const toggle = () => el('[data-testid="api-preview-toggle"]')!
const json = () => el('[data-testid="api-json"]')!.textContent!
async function openPreview() {
await click(toggle())
}
describe('API preview (design state 10)', () => {
it('shows the collapsed bar with the first route and Copy JSON, key falling back to events', async () => {
await mountAt('/schema/new')
const bar = text(el('[data-testid="api-preview"]'))
expect(bar).toContain('API preview')
expect(bar).toContain('GET /v1/content-type/events/entry')
expect(bar).toContain('Copy JSON')
expect(toggle().getAttribute('aria-expanded')).toBe('false')
})
it('opens on the five routes, the sample entry and the hints', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Press mentions')
await openPreview()
expect(toggle().getAttribute('aria-expanded')).toBe('true')
const panel = text(el('[data-testid="api-preview"]'))
expect(panel).toContain('Where entries of this type live')
expect(all('[data-testid="api-route"]').map(text)).toEqual([
'GET /v1/content-type/press-mentions/entry',
'POST /v1/content-type/press-mentions/entry',
'GET /v1/content-type/press-mentions/entry/{id}',
'PUT /v1/content-type/press-mentions/entry/{id}',
'DELETE /v1/content-type/press-mentions/entry/{id}',
])
expect(panel).toContain("A sample entry. Values come from each field's type and default.")
expect(panel).toContain(
'Updates as you edit. Fields you add or remove here appear after you save.',
)
expect(JSON.parse(json())).toEqual({
id: 42,
content_type: 'press-mentions',
data: { title: 'Example text' },
created_by: 3,
updated_by: 3,
created_at: '2026-10-15T11:02:00Z',
updated_at: '2026-10-15T11:02:00Z',
})
expect(json()).toContain('\n "id": 42,')
})
it('updates on every edit, reorder and removal', async () => {
await mountAt('/schema/new')
await openPreview()
await addField('Online')
await pickTile('Yes or no')
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title', 'online'])
expect(JSON.parse(json()).data.online).toBe(false)
await press(byLabel('Move Online'), ' ')
await press(byLabel('Move Online'), 'ArrowUp')
await press(byLabel('Move Online'), ' ')
expect(Object.keys(JSON.parse(json()).data)).toEqual(['online', 'title'])
await click(byLabel('Remove'))
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title'])
})
it('skips a field whose key is invalid', async () => {
await mountAt('/schema/new')
await openPreview()
await click(button('Add field'))
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title'])
})
})
describe('Copy JSON', () => {
it('writes the exact sample, reads Copied for 2 seconds, then Copy JSON again', async () => {
await mountAt('/schema/new')
await openPreview()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await click(el('[data-testid="api-copy"]'))
expect(writeText).toHaveBeenCalledWith(json())
expect(text(el('[data-testid="api-copy"]'))).toBe('Copied')
vi.advanceTimersByTime(1999)
await flushPromises()
expect(text(el('[data-testid="api-copy"]'))).toBe('Copied')
vi.advanceTimersByTime(1)
await flushPromises()
expect(text(el('[data-testid="api-copy"]'))).toBe('Copy JSON')
})
})
describe('API preview on mobile', () => {
it('opens a sheet with the route, the JSON and a full-width Copy JSON that copies', async () => {
await mountAt('/schema/new', { width: MOBILE_WIDTH })
await click(el('[data-testid="api-row"]'))
expect(text(el('[role="dialog"]'))).toContain('API preview')
expect(text(el('[data-testid="api-sheet-route"]'))).toBe(
'GET /v1/content-type/events/entry',
)
const shown = el('[data-testid="api-sheet-json"]')!.textContent!
expect(JSON.parse(shown).content_type).toBe('events')
const copy = el('[data-testid="api-sheet-copy"]')!
expect(copy.className).toContain('flex-1')
expect(text(copy)).toBe('Copy JSON')
await click(copy)
expect(writeText).toHaveBeenCalledWith(shown)
expect(text(copy)).toBe('Copied')
})
})
@@ -0,0 +1,487 @@
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 { useContentTypesStore } from '@/stores/contentTypes'
import {
button,
calls,
createdType,
failWithBody,
mountAt,
networkFailure,
pending,
posts,
text,
unmountAll,
} from './schemaTestKit'
import {
addField,
all,
buildEvents,
byLabel,
chip,
click,
discardButton,
el,
keyField,
keyInput,
labelInput,
main,
nameInput,
rowLabels,
saveButton,
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 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 EVENTS_BODY = {
key: 'events',
name: 'Events',
fields: [
{
key: 'title',
label: 'Title',
type: 'text',
required: true,
unique: true,
default: null,
min: null,
max: 80,
options: [],
},
{
key: 'starts_on',
label: 'Starts on',
type: 'date',
required: false,
unique: false,
default: null,
min: '2026-01-01',
max: '2027-12-31',
options: [],
},
{
key: 'category',
label: 'Category',
type: 'select',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
},
],
}
describe('Saving a valid type', () => {
it('posts once the exact body, in list order, with no icon', async () => {
await mountAt('/schema/new')
await buildEvents()
await click(saveButton())
expect(posts).toHaveLength(1)
expect(posts[0]!.url).toBe('/v1/content-type')
expect(posts[0]!.body).toEqual(EVENTS_BODY)
expect('icon' in posts[0]!.body).toBe(false)
})
})
describe('Saving (design state 11, create)', () => {
async function startSaving() {
const gate = pending()
await mountAt('/schema/new', { create: () => gate.promise })
await buildEvents()
await click(saveButton())
return gate
}
it('shows the chip with a loader, dims and disables the form, and sends one request only', 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(keyInput()!.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('Remove') as HTMLButtonElement).disabled).toBe(true)
saving.click()
await flushPromises()
expect(posts).toHaveLength(1)
})
it('leaves the form editable again once the request settles', async () => {
const gate = await startSaving()
gate.resolve(createdType(posts[0]!.body))
await flushPromises()
expect(posts).toHaveLength(1)
})
})
describe('Double activation while saving', () => {
it('sends one POST however often Save is activated programmatically', async () => {
const gate = pending()
const mounted = await mountAt('/schema/new', { create: () => gate.promise })
await buildEvents()
await click(saveButton())
const actions = mounted.findComponent(BuilderActions)
actions.vm.$emit('save')
actions.vm.$emit('save')
await flushPromises()
expect(posts).toHaveLength(1)
expect((button('Saving…') as HTMLButtonElement).disabled).toBe(true)
expect(actions.props('canSave')).toBe(false)
expect(actions.props('canReveal')).toBe(false)
gate.resolve(createdType(posts[0]!.body))
await flushPromises()
expect(posts).toHaveLength(1)
})
})
describe('Created type reaching the store', () => {
it('inserts the new type before the first later name and needs no second list request', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Newsletters')
await click(saveButton())
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
await flushPromises()
expect(useContentTypesStore().items.map((type) => type.name)).toEqual([
'Events',
'Newsletters',
'Recipes',
'Press mentions',
])
expect(el('nav a[href="/content/newsletters"]')).not.toBeNull()
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
})
})
describe('Saved (design state 12, create)', () => {
it('toasts, adds the type to the store and the sidebar, replaces the URL and does not prompt', async () => {
await mountAt('/schema/new', {
create: (body) => ({ ...createdType(body), fields: new Array(6) }),
})
await buildEvents()
await click(saveButton())
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
await flushPromises()
expect(text(document.body)).toContain('Content type saved')
expect(text(document.body)).toContain('Events now has 6 fields.')
expect(text(document.body)).not.toContain('Leave without saving?')
expect(el('nav a[href="/content/events"]')).not.toBeNull()
const listed = all('[data-testid="schema-row"]').map((row) => text(row))
expect(listed.some((row) => row.includes('Events'))).toBe(true)
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
router.back()
await flushPromises()
expect(router.currentRoute.value.path).not.toBe('/schema/new')
})
it('reads "1 field" for a single field', async () => {
await mountAt('/schema/new', {
create: (body) => ({ ...createdType(body), fields: new Array(1) }),
})
await typeInto(nameInput(), 'Events')
await click(saveButton())
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
await flushPromises()
expect(text(document.body)).toContain('Events now has 1 field.')
})
})
describe('Navigation failing after a created type', () => {
async function saveWithBrokenNavigation() {
vi.spyOn(router, 'replace').mockRejectedValue(new Error('chunk load failed'))
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await click(saveButton())
}
it('keeps the type, shows no failure and blocks a second POST', async () => {
await saveWithBrokenNavigation()
expect(posts).toHaveLength(1)
expect(text(document.body)).not.toContain(SAVE_FAILED_TITLE)
expect(chip()).toBe('')
expect(saveButton()!.disabled).toBe(true)
expect(el('nav a[href="/content/events"]')).not.toBeNull()
expect(text(document.body)).toContain('Content type saved')
})
it('treats the form as editable again after the next edit', async () => {
await saveWithBrokenNavigation()
await typeInto(nameInput(), 'Events 2')
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
})
})
describe('A failed save then an edit', () => {
it('returns to editing so Save works again', async () => {
let attempts = 0
await mountAt('/schema/new', {
create: (body) => {
attempts++
if (attempts === 1) {
throw new Error('boom')
}
return createdType(body)
},
})
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(chip()).toBe('Save failed')
await typeInto(nameInput(), 'Events 2')
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
})
})
describe('Key already taken (design state 14)', () => {
async function reachTaken() {
await mountAt('/schema/new', {
create: failWithBody(409, 'content_type_key_taken'),
})
await typeInto(nameInput(), 'Events')
await click(saveButton())
}
it('shows the message under Key, makes it editable with focus, and counts one problem', async () => {
await reachTaken()
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
'The key "events" is already used by another content type. Choose a different key.',
)
expect(keyInput()!.readOnly).toBe(false)
expect(document.activeElement).toBe(keyInput())
expect(text(keyField())).not.toContain('Auto')
expect(chip()).toBe('1 problem to fix')
expect(saveButton()!.disabled).toBe(true)
expect(nameInput()!.value).toBe('Events')
expect(rowLabels()).toEqual(['Title'])
})
it('clears the error and enables Save when the key is edited', async () => {
await reachTaken()
await typeInto(keyInput(), 'events-2')
expect(keyField().querySelector('[role="alert"]')).toBeNull()
expect(saveButton()!.disabled).toBe(false)
})
})
describe('The server refuses a key or a field', () => {
it('shows the key message under Key for 400 invalid_content_type_key', async () => {
await mountAt('/schema/new', {
create: failWithBody(400, 'invalid_content_type_key'),
})
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
'Use 2 to 40 lowercase letters, digits or hyphens.',
)
expect(chip()).toBe('1 problem to fix')
})
it('shows the banner for 422 invalid_field and keeps the edits and Save', async () => {
await mountAt('/schema/new', { create: failWithBody(422, 'invalid_field') })
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(text(main())).toContain('Check the fields marked in the list.')
expect(nameInput()!.value).toBe('Events')
expect(saveButton()!.disabled).toBe(false)
})
it('shows the access page for 403', async () => {
await mountAt('/schema/new', { create: failWithBody(403, 'Only owners and admins') })
await typeInto(nameInput(), 'Events')
await click(saveButton())
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 mountAt('/schema/new', { create: failWithBody(401, '') })
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(text(main())).not.toContain(SAVE_FAILED_TITLE)
expect(nameInput()!.value).toBe('Events')
})
})
describe('Save failed (design state 13)', () => {
it.each([
['answers 500', failWithBody(500, 'boom')],
['fails (network)', networkFailure],
])(
'shows the alert, keeps every edit and retries with the same body when the POST %s',
async (_, failure) => {
let respond = failure
await mountAt('/schema/new', { create: (body) => respond() ?? body })
await buildEvents()
await click(saveButton())
expect(text(main())).toContain(SAVE_FAILED_TITLE)
expect(text(main())).toContain(SAVE_FAILED_TEXT)
expect(chip()).toBe('Save failed')
expect(nameInput()!.value).toBe('Events')
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Category'])
expect(saveButton()!.disabled).toBe(false)
respond = () => undefined
const first = posts[0]!.body
await click(button('Try again'))
expect(posts).toHaveLength(2)
expect(posts[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 edits, and sends nothing on reconnect', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
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')
setOnline(true)
await flushPromises()
expect(text(document.body)).not.toContain("You're offline.")
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
expect(posts).toHaveLength(0)
})
})
describe('Discard', () => {
it('resets to the blank form, sends nothing and keeps the URL', async () => {
await mountAt('/schema/new')
await buildEvents()
await click(discardButton())
expect(nameInput()!.value).toBe('')
expect(keyInput()!.value).toBe('')
expect(rowLabels()).toEqual(['Title'])
expect(chip()).toBe('')
expect(discardButton()!.disabled).toBe(true)
expect(saveButton()!.disabled).toBe(true)
expect(posts).toHaveLength(0)
expect(router.currentRoute.value.path).toBe('/schema/new')
})
})
describe('Leaving with unsaved edits', () => {
const DIALOG_TITLE = 'Leave without saving?'
it('navigates without a prompt from a pristine form', async () => {
await mountAt('/schema/new')
await router.push('/schema')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
expect(text(document.body)).not.toContain(DIALOG_TITLE)
})
it('asks first when something was edited, and stays on Keep editing', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
const leaving = router.push('/schema')
await flushPromises()
expect(text(document.body)).toContain(DIALOG_TITLE)
expect(button('Keep editing')).toBeInstanceOf(HTMLButtonElement)
expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement)
await click(button('Keep editing'))
await leaving
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema/new')
expect(nameInput()!.value).toBe('Events')
})
it('leaves on Discard changes', async () => {
await mountAt('/schema/new')
await addField('Starts on')
const leaving = router.push('/schema')
await flushPromises()
await click(button('Discard changes'))
await leaving
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
})
})
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { button, mountAt, text, unmountAll } from './schemaTestKit'
import { byLabel, click, el, labelInput, rowLabels, rows, 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 dialog = () => el('[role="dialog"]')
describe('Selecting a field on mobile', () => {
it('opens a sheet titled with the field, with the inspector, the move and remove buttons and Done', async () => {
await mountAt('/schema/new', { width: MOBILE_WIDTH })
await click(rows()[0]!.querySelector('[data-field-row]'))
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Title')
expect(labelInput()!.value).toBe('Title')
expect(byLabel('Move up')).not.toBeNull()
expect(byLabel('Move down')).not.toBeNull()
expect(byLabel('Remove')).not.toBeNull()
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
expect(document.activeElement).toBe(el('[data-testid="field-sheet-title"]'))
})
it('closes with Done and returns the focus to the row', async () => {
await mountAt('/schema/new', { width: MOBILE_WIDTH })
const row = rows()[0]!.querySelector<HTMLElement>('[data-field-row]')!
await click(row)
await click(button('Done'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(document.activeElement).toBe(row)
})
it('updates the title with the label and removes the field from the sheet', async () => {
await mountAt('/schema/new', { width: MOBILE_WIDTH })
await click(rows()[0]!.querySelector('[data-field-row]'))
await typeInto(labelInput(), 'Headline')
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Headline')
await click(byLabel('Remove'))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(rowLabels()).toEqual([])
})
it('opens the sheet on a new field with the label focused', async () => {
await mountAt('/schema/new', { width: MOBILE_WIDTH })
await click(button('Add field'))
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Untitled field')
expect(rowLabels()).toHaveLength(2)
})
})
@@ -0,0 +1,250 @@
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 { button, calls, failWith, mountAt, text, unmountAll } from './schemaTestKit'
import {
all,
chip,
click,
discardButton,
el,
inspector,
keyField,
keyInput,
labelInput,
main,
nameInput,
rowLabels,
saveButton,
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 NOT_FOUND_TITLE = 'Content type not found'
const ACCESS_TITLE = "You don't have access to Schema"
function newTypeButtons() {
return all('button').filter((candidate) => text(candidate) === 'New content type')
}
describe('Reaching the builder', () => {
it('opens from the header button', async () => {
await mountAt('/schema')
await click(newTypeButtons()[0])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
expect(nameInput()).not.toBeNull()
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
})
it('opens from the empty-state button', async () => {
await mountAt('/schema', { content: () => [] })
await click(newTypeButtons().at(-1))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
expect(nameInput()).not.toBeNull()
})
it('opens from the mobile floating button', async () => {
await mountAt('/schema', { width: MOBILE_WIDTH })
await click(newTypeButtons().find((candidate) => candidate.className.includes('fixed')))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
expect(nameInput()).not.toBeNull()
})
it.each(['/schema/events', '/schema/unknown'])('still says not found at %s', async (path) => {
await mountAt(path)
expect(text(main())).toContain(NOT_FOUND_TITLE)
expect(nameInput()).toBeNull()
})
})
describe('Roles and sessions', () => {
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
await mountAt('/schema/new', { role })
expect(nameInput()).not.toBeNull()
expect(text(main())).not.toContain(ACCESS_TITLE)
})
it('tells a verified user who is neither to go home, leaving the URL', async () => {
await mountAt('/schema/new', { role: 'user' })
expect(text(main())).toContain(ACCESS_TITLE)
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
expect(router.currentRoute.value.path).toBe('/schema/new')
expect(nameInput()).toBeNull()
expect(el('[data-testid="field-row"]')).toBeNull()
})
it('shows the same page when the content types answer 403', async () => {
await mountAt('/schema/new', { content: failWith(403) })
expect(text(main())).toContain(ACCESS_TITLE)
})
})
describe('The builder needs no built-in list', () => {
it('calls the custom list once, never the built-in one, and shows no skeleton', async () => {
await mountAt('/schema/new')
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
expect(calls).not.toContain('/v1/builtin-type')
expect(nameInput()).not.toBeNull()
expect(el('[data-testid="schema-skeleton-row"]')).toBeNull()
})
it('shows the builder while the custom list is still loading', async () => {
let release!: () => void
const gate = new Promise<void>((done) => (release = done))
await mountAt('/schema/new', { content: () => gate.then(() => []) })
expect(nameInput()).not.toBeNull()
release()
await flushPromises()
})
})
describe('New type, desktop (design state 1)', () => {
it('shows the breadcrumb, Name, Key and the list header', async () => {
await mountAt('/schema/new')
expect(text(el('nav[aria-label="breadcrumb"]'))).toBe('Schema New content type')
expect(el('nav[aria-label="breadcrumb"] a[href="/schema"]')).not.toBeNull()
expect(all('nav[aria-label="breadcrumb"] a')).toHaveLength(1)
expect(nameInput()!.value).toBe('')
expect(nameInput()!.placeholder).toBe('For example, Events')
expect(nameInput()!.required).toBe(true)
expect(nameInput()!.maxLength).toBe(100)
expect(keyInput()!.value).toBe('')
expect(keyInput()!.placeholder).toBe('events')
expect(keyInput()!.readOnly).toBe(true)
expect(text(main())).toContain(
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.",
)
expect(text(main())).toContain('Fields')
expect(text(el('[data-testid="field-count"]'))).toBe('1 of 50')
})
it('shows the starter row and the inspector of Title', async () => {
await mountAt('/schema/new')
expect(rowLabels()).toEqual(['Title'])
const row = el('[data-testid="field-row"]')!
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('title')
expect(row.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe('true')
expect(text(row.querySelector('[data-testid="field-row-badges"]'))).toBe('New')
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
expect(labelInput()!.value).toBe('Title')
expect(el<HTMLInputElement>('input[name="field-key"]')!.value).toBe('title')
const copy = text(inspector())
expect(copy).toContain('Auto')
expect(copy).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
expect(all('[role="radio"]')).toHaveLength(6)
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
'Text',
)
expect(copy).toContain("An entry can't be saved with this field empty.")
expect(copy).toContain('Unique values')
expect(copy).toContain('Used when an entry is created and this field is left out.')
expect(copy).toContain('Shortest')
expect(copy).toContain('Longest')
expect(copy).toContain('Counted in characters. Entries show a counter.')
expect(all('[role="switch"]').map((toggle) => toggle.getAttribute('aria-checked'))).toEqual(
['false', 'false'],
)
})
it('disables Save and Discard, shows no chip, no entries bar and a collapsed API bar', async () => {
await mountAt('/schema/new')
expect(saveButton()!.disabled).toBe(true)
expect(discardButton()!.disabled).toBe(true)
expect(chip()).toBe('')
expect(text(main())).not.toContain('entries use this content type')
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
'false',
)
expect(el('[data-testid="api-json"]')).toBeNull()
})
})
describe('New type, mobile (design state 1)', () => {
it('shows the topbar, stacked Name and Key, the list and the API preview row', async () => {
await mountAt('/schema/new', { width: MOBILE_WIDTH })
expect(el('a[href="/schema"][aria-label="Schema"]')).not.toBeNull()
expect(text(el('[data-slot="title"]'))).toBe('New content type')
expect(nameInput()).not.toBeNull()
expect(keyInput()).not.toBeNull()
expect(rowLabels()).toEqual(['Title'])
expect(text(el('[data-testid="api-row"]'))).toBe('API preview')
expect(el('[data-testid="api-row"] svg')).not.toBeNull()
expect(inspector()).toBeNull()
expect(el('[data-testid="inspector-pane"]')).toBeNull()
expect(discardButton()).toBeInstanceOf(HTMLButtonElement)
expect(saveButton()).toBeInstanceOf(HTMLButtonElement)
expect(el('[data-testid="api-preview"]')).toBeNull()
})
})
describe('The key follows the name until it is edited', () => {
it.each([
['Events', 'events', 'auto'],
['Press mentions', 'press-mentions', 'auto'],
['Café & Bar', 'cafe-bar', 'auto'],
[' Job openings!', 'job-openings', 'auto'],
['2026 Awards', '2026-awards', 'auto'],
['!!!', '', 'empty'],
['x'.repeat(60), 'x'.repeat(40), 'auto'],
])('%j gives the key %j', async (name, key, mode) => {
await mountAt('/schema/new')
await typeInto(nameInput(), name)
expect(keyInput()!.value).toBe(key)
expect(keyInput()!.readOnly).toBe(true)
expect(text(keyField()).includes('Auto')).toBe(mode === 'auto')
})
it('unlocks with the pencil, keeps a typed key, and resets to the name', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await click(keyField().querySelector('button[aria-label="Edit key"]'))
expect(keyInput()!.readOnly).toBe(false)
expect(text(keyField())).not.toContain('Auto')
expect(button('Reset to match the name')).toBeInstanceOf(HTMLButtonElement)
await typeInto(keyInput(), 'agenda')
await typeInto(nameInput(), 'Events and talks')
expect(keyInput()!.value).toBe('agenda')
await click(button('Reset to match the name'))
expect(keyInput()!.value).toBe('events-and-talks')
expect(keyInput()!.readOnly).toBe(true)
expect(text(keyField())).toContain('Auto')
})
})
@@ -178,7 +178,7 @@ describe('Schema built-in view: loading', () => {
})
describe('Schema built-in view: unknown key', () => {
it.each(['/schema/unknown', '/schema/events', '/schema/new'])(
it.each(['/schema/unknown', '/schema/events'])(
'shows the not-found page at %s without redirecting',
async (path) => {
await mountAt(path)
@@ -204,7 +204,7 @@ describe('Schema built-in view: error', () => {
expect(text(main())).toContain(
'GET /v1/builtin-type failed. Check your connection and try again.',
)
expect(button('Try again')).toBeDefined()
expect(button('Try again')).toBeInstanceOf(HTMLButtonElement)
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
respond = () => builtinTypes
+107
View File
@@ -0,0 +1,107 @@
import { flushPromises } from '@vue/test-utils'
import { button, text } from './schemaTestKit'
export function el<T extends HTMLElement = HTMLElement>(selector: string) {
return document.body.querySelector<T>(selector)
}
export function all<T extends HTMLElement = HTMLElement>(selector: string) {
return [...document.body.querySelectorAll<T>(selector)]
}
export async function typeInto(
input: HTMLInputElement | HTMLTextAreaElement | null | undefined,
value: string,
) {
input!.value = value
input!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function press(target: Element | null | undefined, key: string) {
target!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
await flushPromises()
}
export async function click(target: Element | null | undefined) {
;(target as HTMLElement).click()
await flushPromises()
}
export const nameInput = () => el<HTMLInputElement>('input[name="name"]')
export const keyInput = () => el<HTMLInputElement>('input[name="key"]')
export const keyField = () => keyInput()!.closest<HTMLElement>('[data-orientation]')!
export const labelInput = () => el<HTMLInputElement>('input[name="label"]')
export const fieldKeyInput = () => el<HTMLInputElement>('input[name="field-key"]')
export const saveButton = () => button('Save') as HTMLButtonElement | undefined
export const discardButton = () => button('Discard') as HTMLButtonElement | undefined
export const byLabel = (label: string) => el<HTMLButtonElement>(`[aria-label="${label}"]`)
export const rows = () => all('[data-testid="field-row"]')
export const rowLabels = () =>
rows().map((row) => text(row.querySelector('[data-testid="field-row-label"]')))
export const chip = () => text(el('[data-testid="builder-chip"]'))
export const main = () => el('#dashboard-content')!
export const inspector = () => el('[data-testid="field-inspector"]')
export const live = () => text(el('[data-testid="reorder-live"]'))
export async function addField(label: string) {
await click(button('Add field'))
await typeInto(labelInput(), label)
}
export async function selectRow(label: string) {
const row = rows().find(
(candidate) => text(candidate.querySelector('[data-testid="field-row-label"]')) === label,
)
await click(row!.querySelector('[data-field-row]'))
}
export function tile(name: string) {
return all('[role="radio"]').find((radio) =>
text(radio.closest('label')).startsWith(name),
) as HTMLButtonElement
}
export async function pickTile(name: string) {
await click(tile(name))
}
export function switchOf(label: string) {
const found = all('[data-testid="field-inspector"] label').find((candidate) =>
text(candidate).startsWith(label),
)
return document.getElementById(found!.getAttribute('for')!) as HTMLButtonElement
}
export async function toggle(label: string) {
await click(switchOf(label))
}
export const limit = (which: 'min' | 'max') => el<HTMLInputElement>(`input[name="${which}"]`)
export const defaultInput = () => el<HTMLInputElement>('input[name="default"]')
export const addChoiceInput = () => el<HTMLInputElement>('input[aria-label="Add a choice"]')
export async function addChoice(value: string) {
await typeInto(addChoiceInput(), value)
await click(button('Add'))
}
export function choiceValues() {
return all<HTMLInputElement>('[data-testid="choice-row"] input').map((input) => input.value)
}
export async function buildEvents() {
await typeInto(nameInput(), 'Events')
await toggle('Required')
await toggle('Unique values')
await typeInto(limit('max'), '80')
await addField('Starts on')
await pickTile('Date')
await typeInto(limit('min'), '2026-01-01')
await typeInto(limit('max'), '2027-12-31')
await addField('Category')
await pickTile('Choice')
await addChoice('Talk')
await addChoice('Workshop')
await addChoice('Meetup')
}
+31 -2
View File
@@ -76,6 +76,16 @@ export const builtinTypes = [
fields: key === 'articles' ? articleFields : new Array(count),
}))
export function createdType(body: Record<string, unknown>) {
return {
id: 4,
icon: null,
created_at: '2026-10-19T09:30:00Z',
updated_at: '2026-10-19T09:30:00Z',
...body,
}
}
export function manyTypes(count: number) {
return Array.from({ length: count }, (_, index) => ({
...customTypes[0]!,
@@ -92,12 +102,24 @@ export function failWith(status: number): Responder {
}
}
export function failWithBody(status: number, body: string): Responder {
return () => {
throw new ApiError(status, undefined, body)
}
}
export const networkFailure: Responder = () => {
throw new NetworkError()
}
export interface PostedRequest {
url: string
body: Record<string, unknown>
}
export interface SchemaMount {
role?: string
create?: (body: PostedRequest['body']) => unknown
width?: number
content?: Responder
builtin?: Responder
@@ -107,9 +129,11 @@ export interface SchemaMount {
let wrapper: VueWrapper | undefined
export const calls: string[] = []
export const posts: PostedRequest[] = []
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
calls.length = 0
posts.length = 0
localStorage.clear()
setViewportWidth(options.width ?? DESKTOP_WIDTH)
const pinia = createPinia()
@@ -130,10 +154,15 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
role: (options.role ?? 'owner') as never,
}
session.status = 'ready'
const request = vi.fn(async (url: string) => {
const request = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as PostedRequest['body']
posts.push({ url, body })
return (options.create ?? (() => createdType(body)))(body)
}
calls.push(url)
if (url === '/v1/content-type') {
return (options.content ?? (() => customTypes))()
return (options.content ?? (() => [...customTypes]))()
}
if (url === '/v1/builtin-type') {
return (options.builtin ?? (() => builtinTypes))()