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

+10
View File
@@ -398,3 +398,13 @@ body {
var(--ui-bg-accented) 8px 16px
);
}
.no-spin {
appearance: textfield;
-moz-appearance: textfield;
}
.no-spin::-webkit-inner-spin-button,
.no-spin::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useJsonCopy } from '@/composables/useJsonCopy'
import { previewRoutes } from '@/lib/schemaBuilderSample'
const props = defineProps<{ keyValue: string; json: string }>()
const open = defineModel<boolean>('open', { default: false })
const { copied, copy, label: copyLabel, icon: copyIcon } = useJsonCopy()
const routes = computed(() => previewRoutes(props.keyValue))
const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.path}`)
</script>
<template>
<section
:aria-label="t('API preview')"
class="flex shrink-0 flex-col"
data-testid="api-preview"
>
<div
class="flex h-11 items-center gap-2.5 border-t border-default bg-default px-3 text-highlighted md:px-6"
>
<UButton
color="neutral"
variant="ghost"
:icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
:aria-expanded="open"
data-testid="api-preview-toggle"
class="h-11 min-w-0 flex-1 justify-start gap-2.5 px-0 font-semibold hover:bg-transparent"
:ui="{ leadingIcon: 'size-3.5' }"
@click="open = !open"
>
<span>{{ t('API preview') }}</span>
<span
class="min-w-0 flex-1 truncate text-left font-mono text-xs font-normal text-muted"
>{{ summary }}</span
>
</UButton>
<UButton
:icon="copyIcon"
color="neutral"
variant="outline"
data-testid="api-copy"
class="h-7 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0"
:class="copied ? 'text-success' : ''"
:ui="{ leadingIcon: 'size-4' }"
@click="copy(json)"
>
<span aria-live="polite">{{ copyLabel }}</span>
</UButton>
</div>
<UCollapsible :open="open" :ui="{ content: 'overflow-hidden' }">
<template #content>
<div class="grid h-62 grid-cols-12 grid-rows-1 gap-4 bg-default px-6 pb-4">
<div class="col-span-5 flex min-h-0 min-w-0 flex-col gap-1.5">
<span class="text-xs text-muted">{{
t('Where entries of this type live')
}}</span>
<ul
class="m-0 flex list-none flex-col gap-0.5 border border-default bg-elevated px-3 py-2.5"
>
<li
v-for="(route, index) in routes"
:key="index"
data-testid="api-route"
class="truncate font-mono text-xs/5 text-highlighted"
>
<span class="inline-block w-14 text-muted">{{ route.method }}</span
>{{ route.path }}
</li>
</ul>
<span class="text-xs/4.5 text-pretty text-muted">{{
t(
'Updates as you edit. Fields you add or remove here appear after you save.',
)
}}</span>
</div>
<div class="col-span-7 flex min-h-0 min-w-0 flex-col gap-1.5">
<span class="text-xs text-muted">{{
t("A sample entry. Values come from each field's type and default.")
}}</span>
<pre
tabindex="0"
data-testid="api-json"
class="m-0 min-h-0 flex-1 overflow-auto border border-default bg-elevated px-3 py-2.5 font-mono text-xs/5 whitespace-pre text-highlighted"
>{{ json }}</pre
>
</div>
</div>
</template>
</UCollapsible>
</section>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useJsonCopy } from '@/composables/useJsonCopy'
import { previewRoutes } from '@/lib/schemaBuilderSample'
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
const props = defineProps<{ keyValue: string; json: string }>()
const open = defineModel<boolean>('open', { default: false })
const { copy, label: copyLabel, icon: copyIcon } = useJsonCopy()
const first = computed(() => previewRoutes(props.keyValue)[0]!)
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
:title="t('API preview')"
:description="t('Where entries of this type live')"
:ui="BOTTOM_SHEET_UI"
>
<template #body>
<span class="text-xs text-muted" data-testid="api-sheet-route">{{
`${first.method} ${first.path}`
}}</span>
<pre
tabindex="0"
data-testid="api-sheet-json"
class="m-0 min-h-0 flex-1 overflow-auto border border-default bg-elevated px-3 py-2.5 font-mono text-xs/5 whitespace-pre text-highlighted"
>{{ json }}</pre
>
</template>
<template #footer>
<UButton
:icon="copyIcon"
data-testid="api-sheet-copy"
class="h-11 flex-1 justify-center"
@click="copy(json)"
>
<span aria-live="polite">{{ copyLabel }}</span>
</UButton>
</template>
</USlideover>
</template>
+2 -1
View File
@@ -5,7 +5,8 @@ import { computed, nextTick, ref, useId } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import type { Byline } from '@/composables/useArticles'
import { insertionTarget, moveItem } from '@/lib/articleMeta'
import { moveItem } from '@/lib/arrayMove'
import { insertionTarget } from '@/lib/articleMeta'
const DESKTOP_QUERY = '(min-width: 64rem)'
const DESKTOP_AVATAR = 22
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { t } from '@/i18n'
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
defineProps<{
status: BuilderStatus
problems: number
canSave: boolean
canReveal: boolean
canDiscard: boolean
saving: boolean
}>()
defineEmits<{ save: []; discard: [] }>()
</script>
<template>
<div class="flex shrink-0 items-center gap-2 md:gap-3">
<BuilderStatusChip :status="status" :problems="problems" />
<span class="flex-1 md:hidden" />
<UButton
color="neutral"
variant="outline"
:disabled="!canDiscard"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
@click="$emit('discard')"
>
{{ t('Discard') }}
</UButton>
<UButton
:disabled="!canSave && !canReveal"
:aria-disabled="canReveal ? 'true' : undefined"
:loading="saving"
class="h-11 px-4 md:h-8"
@click="$emit('save')"
>
{{ saving ? t('Saving…') : t('Save') }}
</UButton>
</div>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{ kind: 'new' | 'required' | 'unique'; onSand?: boolean }>()
const badges = {
new: {
get label() {
return t('New')
},
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
},
required: {
get label() {
return t('Required')
},
class: 'border border-accented bg-transparent text-toned',
},
unique: {
get label() {
return t('Unique')
},
class: 'border border-accented bg-transparent text-toned',
},
} as const
const OUTLINE_ON_SAND = 'border border-(--rc-on-sand) bg-transparent text-(--rc-on-sand)'
const badge = computed(() => badges[props.kind])
const badgeClass = computed(() =>
props.onSand && props.kind !== 'new' ? OUTLINE_ON_SAND : badge.value.class,
)
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:class="badgeClass"
:ui="{ base: 'h-5 shrink-0 px-1.5 py-0 text-xs font-medium whitespace-nowrap' }"
>
{{ badge.label }}
</UBadge>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import FieldInspector from '@/components/FieldInspector.vue'
import type { useSchemaBuilder } from '@/composables/useSchemaBuilder'
import type { BuilderField } from '@/lib/schemaBuilderModel'
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
type Builder = ReturnType<typeof useSchemaBuilder>
const props = defineProps<{
builder: Builder
field: BuilderField
errors: FieldErrors
compact: boolean
}>()
const uid = () => props.field.uid
</script>
<template>
<FieldInspector
:field="field"
:errors="errors"
:disabled="builder.saving.value"
:compact="compact"
:focus-token="builder.labelFocusToken.value"
@label="builder.setLabel(uid(), $event)"
@key="builder.setFieldKey(uid(), $event)"
@unlock="builder.unlockFieldKey(uid())"
@reset="builder.resetFieldKey(uid())"
@type="builder.setFieldType(uid(), $event)"
@required="builder.patchField(uid(), { required: $event })"
@unique="builder.patchField(uid(), { unique: $event })"
@limit="(which, value) => builder.setLimit(uid(), which, value)"
@default="builder.setDefault(uid(), $event)"
@default-on="builder.patchField(uid(), { defaultOn: $event })"
@add-choice="builder.addChoice(uid(), $event)"
@remove-choice="builder.removeChoice(uid(), $event)"
@rename-choice="(index, text) => builder.renameChoice(uid(), index, text)"
@reorder-choices="builder.reorderChoices(uid(), $event)"
/>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
const props = defineProps<{ status: BuilderStatus; problems: number }>()
const text = computed(() => {
switch (props.status) {
case 'saving':
return t('Saving…')
case 'offline':
return t('Waiting for connection')
case 'failed':
return t('Save failed')
case 'problems':
return props.problems === 1
? t('1 problem to fix')
: t('{0} problems to fix', [props.problems])
default:
return t('Unsaved changes')
}
})
</script>
<template>
<span
v-if="status !== 'none'"
role="status"
data-testid="builder-chip"
class="inline-flex shrink-0 items-center gap-1.5 text-xs whitespace-nowrap"
:class="{
'text-warning': status === 'dirty',
'text-error': status === 'failed' || status === 'problems',
'text-muted': status === 'saving' || status === 'offline',
}"
>
<UIcon
v-if="status === 'saving'"
name="i-lucide-loader-circle"
class="size-3.5 animate-spin"
/>
<UIcon v-else-if="status === 'offline'" name="i-lucide-wifi-off" class="size-3.5" />
<UIcon v-else-if="status === 'dirty'" name="i-lucide-circle-dot" class="size-3.5" />
<UIcon v-else name="i-lucide-circle-alert" class="size-3.5" />
<span>{{ text }}</span>
</span>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import SlugField from '@/components/SlugField.vue'
import {
BUILDER_LIMITS,
PREVIEW_KEY_FALLBACK,
keyDisplayMode,
type KeyMode,
} from '@/lib/schemaBuilderModel'
const props = defineProps<{
name: string
keyValue: string
keyMode: KeyMode
nameError?: string
keyError?: string
disabled: boolean
keyFocusToken: number
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:key': [value: string]
unlock: []
reset: []
}>()
const root = ref<HTMLElement | null>(null)
const slugMode = computed(() => keyDisplayMode(props.keyMode, props.keyValue))
const hasError = computed(() => Boolean(props.nameError || props.keyError))
watch(
() => props.keyFocusToken,
async () => {
await nextTick()
root.value?.querySelector<HTMLInputElement>('input[name="key"]')?.focus()
},
)
</script>
<template>
<div
ref="root"
class="flex min-w-0 flex-col gap-3 transition-opacity md:flex-row md:gap-4"
:class="[hasError ? 'md:items-start' : 'md:items-end', disabled ? 'opacity-60' : '']"
>
<div class="md:w-80">
<DirectoryField :label="t('Name')" name="name" required :error="nameError">
<UInput
:model-value="name"
:placeholder="t('For example, Events')"
:maxlength="BUILDER_LIMITS.nameMax"
:disabled="disabled"
:color="nameError ? 'error' : undefined"
required
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="emit('update:name', String($event))"
/>
</DirectoryField>
</div>
<div :class="keyError ? 'md:w-90' : 'md:w-80'">
<SlugField
:label="t('Key')"
name="key"
:mode="slugMode"
:value="keyValue"
:error="keyError"
:disabled="disabled"
dense
reset-in-label
:help="
t(
'Used in URLs and the API. Lowercase letters, digits and hyphens. It can\'t change after you save.',
)
"
:edit-label="t('Edit key')"
:placeholder="PREVIEW_KEY_FALLBACK"
:maxlength="BUILDER_LIMITS.typeKeyMax"
@update:value="emit('update:key', $event)"
@unlock="emit('unlock')"
@reset="emit('reset')"
/>
</div>
<span class="hidden flex-1 md:block" />
<div v-if="$slots.actions" :class="hasError ? 'md:mt-5.5' : ''">
<slot name="actions" />
</div>
</div>
</template>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
import { BUILDER_LIMITS } from '@/lib/schemaBuilderModel'
const props = defineProps<{
options: string[]
rowErrors?: Record<number, string>
listError?: string
disabled: boolean
}>()
const emit = defineEmits<{
add: [text: string]
remove: [index: number]
rename: [index: number, text: string]
reorder: [options: string[]]
}>()
const root = ref<HTMLElement | null>(null)
const draft = ref('')
const addInput = ref<{ inputRef?: HTMLInputElement | null } | null>(null)
interface ChoiceRow {
index: number
text: string
}
const reorder = useKeyboardReorder<ChoiceRow>({
items: () => props.options.map((text, index) => ({ index, text })),
name: (row) => row.text.trim() || t('Choice'),
commit: (next) =>
emit(
'reorder',
next.map((row) => row.text),
),
root: () => root.value,
})
const rows = computed(() => reorder.shown.value)
const full = computed(() => props.options.length >= BUILDER_LIMITS.choicesMax)
const countText = computed(() => t('{0} of {1}', [props.options.length, BUILDER_LIMITS.choicesMax]))
const canAdd = computed(() => !props.disabled && !full.value && draft.value.trim() !== '')
async function add() {
if (!canAdd.value) {
return
}
emit('add', draft.value)
draft.value = ''
await nextTick()
addInput.value?.inputRef?.focus()
}
</script>
<template>
<div ref="root" class="flex flex-col gap-2" data-testid="choice-list">
<div class="flex justify-between">
<span class="font-medium text-highlighted"
>{{ t('Choices') }}<span aria-hidden="true"> *</span></span
>
<span class="font-mono text-xs text-muted" data-testid="choice-count">{{
countText
}}</span>
</div>
<div
role="status"
aria-live="polite"
data-testid="choice-live"
:class="
reorder.active.value
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
: 'sr-only'
"
>
{{ reorder.announcement.value }}
</div>
<ul class="m-0 flex list-none flex-col gap-2 p-0">
<li
v-for="(row, index) in rows"
:key="row.index"
data-testid="choice-row"
class="flex flex-col gap-1"
>
<div
class="flex items-center gap-1"
:class="reorder.grabbed.value === index ? 'outline-2 outline-primary' : ''"
>
<button
type="button"
:data-handle="index"
:aria-label="t('Move {0}', [row.text.trim() || t('Choice')])"
:aria-pressed="reorder.grabbed.value === index"
:disabled="disabled"
class="flex size-7 shrink-0 items-center justify-center text-muted focus-visible:outline-2 focus-visible:outline-primary max-md:size-11"
@keydown="reorder.onKeydown($event, index)"
>
<UIcon name="i-lucide-grip-vertical" class="size-4" />
</button>
<UInput
:model-value="row.text"
:placeholder="t('Choice')"
:disabled="disabled"
:color="rowErrors?.[row.index] ? 'error' : undefined"
:aria-label="t('Choice {0}', [index + 1])"
:aria-invalid="rowErrors?.[row.index] ? 'true' : undefined"
:aria-describedby="
rowErrors?.[row.index] ? `choice-error-${row.index}` : undefined
"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="emit('rename', row.index, String($event))"
/>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
:disabled="disabled"
:aria-label="t('Remove {0}', [row.text.trim() || t('Choice')])"
class="size-7 justify-center max-md:size-11"
@click="emit('remove', row.index)"
/>
</div>
<span
v-if="rowErrors?.[row.index]"
:id="`choice-error-${row.index}`"
role="alert"
class="ml-8 flex items-start gap-1.5 text-xs/4.5 text-error max-md:ml-12"
>
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ rowErrors[row.index] }}</span>
</span>
</li>
</ul>
<p v-if="listError" role="alert" class="flex items-start gap-1.5 text-xs/4.5 text-error">
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ listError }}</span>
</p>
<div class="flex gap-2 pl-8 max-md:pl-12">
<UInput
ref="addInput"
v-model="draft"
:placeholder="t('Add a choice')"
:aria-label="t('Add a choice')"
:disabled="disabled || full"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 border-dashed text-lg md:h-8 md:text-sm' }"
@keydown.enter.prevent="add"
/>
<UButton
icon="i-lucide-plus"
color="neutral"
variant="outline"
:disabled="!canAdd"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
@click="add"
>
{{ t('Add') }}
</UButton>
</div>
<p class="text-xs/4.5 text-pretty text-muted">
{{
t(
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.',
)
}}
</p>
</div>
</template>
+6 -3
View File
@@ -16,6 +16,7 @@ import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UpdatePill from '@/components/UpdatePill.vue'
import UserMenu from '@/components/UserMenu.vue'
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
const props = withDefaults(
defineProps<{
@@ -67,7 +68,9 @@ const update = useUpdateStore()
const api = useApi()
const contentTypes = useContentTypesStore()
const wantsBuiltin = computed(() => session.canManageContent && /^\/schema(\/|$)/.test(route.path))
const wantsBuiltin = computed(
() => session.canManageContent && route.meta.needsBuiltinTypes === true,
)
watch(
[() => session.status === 'ready' && session.session?.verified === true, wantsBuiltin],
@@ -115,7 +118,7 @@ const schemaItem = computed(() =>
get label() {
return t('Schema')
},
to: '/schema',
to: SCHEMA_PATH,
icon: 'i-lucide-shapes',
},
]
@@ -128,7 +131,7 @@ const navItems = computed(() =>
get label() {
return t('Home')
},
to: '/home',
to: HOME_PATH,
icon: 'i-lucide-home',
},
{
+3 -2
View File
@@ -5,11 +5,12 @@ withDefaults(
defineProps<{
label?: string
optional?: boolean
required?: boolean
error?: string
help?: string
name?: string
}>(),
{ label: '', optional: false, error: '', help: '', name: undefined },
{ label: '', optional: false, required: false, error: '', help: '', name: undefined },
)
</script>
@@ -29,7 +30,7 @@ withDefaults(
}"
>
<template v-if="label" #label>
{{ label }}
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
</template>
<template v-if="$slots.hint" #hint>
+4 -22
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import RetryAlert from '@/components/RetryAlert.vue'
defineProps<{ title: string; description?: string }>()
@@ -7,28 +8,9 @@ defineEmits<{ retry: [] }>()
</script>
<template>
<UAlert
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
<RetryAlert
:title="title"
:description="description ?? t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="$emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@retry="$emit('retry')"
/>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ first: boolean; last: boolean; disabled: boolean }>()
defineEmits<{ up: []; down: []; remove: [] }>()
const BUTTON_CLASS = 'size-7 justify-center max-md:size-11'
</script>
<template>
<div class="flex shrink-0 gap-0.5">
<UButton
icon="i-lucide-chevron-up"
color="neutral"
variant="ghost"
data-action="up"
:aria-label="t('Move up')"
:disabled="disabled || first"
:class="BUTTON_CLASS"
@click.stop="$emit('up')"
/>
<UButton
icon="i-lucide-chevron-down"
color="neutral"
variant="ghost"
data-action="down"
:aria-label="t('Move down')"
:disabled="disabled || last"
:class="BUTTON_CLASS"
@click.stop="$emit('down')"
/>
<UButton
icon="i-lucide-trash-2"
color="neutral"
variant="ghost"
data-action="remove"
:aria-label="t('Remove')"
:disabled="disabled"
:class="BUTTON_CLASS"
@click.stop="$emit('remove')"
/>
</div>
</template>
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import AppSwitch from '@/components/AppSwitch.vue'
import ChoiceList from '@/components/ChoiceList.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import FieldTypePicker from '@/components/FieldTypePicker.vue'
import SlugField from '@/components/SlugField.vue'
import {
BUILDER_LIMITS,
STARTER_KEY,
keyDisplayMode,
supportsLimits,
supportsUnique,
trimmedOptions,
type BuilderField,
type FieldType,
} from '@/lib/schemaBuilderModel'
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
const NO_DEFAULT = 'none'
const CHOICE_PREFIX = 'choice:'
const props = defineProps<{
field: BuilderField
errors: FieldErrors
disabled: boolean
compact: boolean
focusToken: number
}>()
const emit = defineEmits<{
label: [value: string]
key: [value: string]
unlock: []
reset: []
type: [type: FieldType]
required: [value: boolean]
unique: [value: boolean]
limit: [which: 'min' | 'max', value: string]
default: [value: string]
defaultOn: [value: boolean]
addChoice: [text: string]
removeChoice: [index: number]
renameChoice: [index: number, text: string]
reorderChoices: [options: string[]]
}>()
const root = ref<HTMLElement | null>(null)
const type = computed(() => props.field.type)
const hasLimits = computed(() => supportsLimits(type.value))
const canUnique = computed(() => supportsUnique(type.value))
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
const limitLabels = computed(() => {
if (type.value === 'number') {
return [t('Lowest value'), t('Highest value')] as const
}
if (type.value === 'date') {
return [t('Earliest date'), t('Latest date')] as const
}
return [t('Shortest'), t('Longest')] as const
})
const limitInput = computed(() => {
if (type.value === 'date') {
return 'date'
}
return 'number'
})
const isTextual = computed(() => type.value === 'text' || type.value === 'richtext')
const limitSuffix = computed(() => (isTextual.value ? t('characters') : ''))
const maxHelp = computed(() =>
isTextual.value
? t('Counted in characters. Entries show a counter.')
: t('Entries outside the range are refused.'),
)
const noLimitsText = computed(() =>
type.value === 'boolean'
? t('Yes or no fields have no limits.')
: t('Limits come from the choices below.'),
)
const uniqueHelp = computed(() => {
if (canUnique.value) {
return t('Each entry needs a different value.')
}
return type.value === 'richtext'
? t('Not available for rich text.')
: t('Not available for yes or no fields.')
})
const defaultHelp = computed(() =>
props.field.unique
? t("A field with unique values can't have a default.")
: t('Used when an entry is created and this field is left out.'),
)
const defaultOnHelp = computed(() =>
props.field.defaultOn
? t('New entries start with this switch on when the field is left out.')
: t('New entries start with this switch off when the field is left out.'),
)
const choiceItems = computed(() => [
{ label: t('None'), value: NO_DEFAULT },
...Array.from(new Set(trimmedOptions(props.field).filter((option) => option !== ''))).map(
(option) => ({ label: option, value: `${CHOICE_PREFIX}${option}` }),
),
])
const choiceValue = computed(() =>
props.field.defaultValue === '' ? NO_DEFAULT : `${CHOICE_PREFIX}${props.field.defaultValue}`,
)
function onChoice(value: unknown) {
const text = String(value)
emit('default', text === NO_DEFAULT ? '' : text.slice(CHOICE_PREFIX.length))
}
const SWITCH_UI = {
root: 'items-start gap-3',
wrapper: 'ms-0',
label: 'font-medium text-highlighted',
description: 'mt-0 text-xs/4.5 text-pretty text-muted',
}
watch(
() => props.focusToken,
async () => {
await nextTick()
root.value?.querySelector<HTMLInputElement>('input[name="label"]')?.focus()
},
)
</script>
<template>
<div
ref="root"
class="flex min-w-0 flex-col gap-3.5 transition-opacity max-md:gap-4"
:class="disabled ? 'opacity-60' : ''"
data-testid="field-inspector"
>
<div class="grid gap-3.5" :class="gridClass">
<DirectoryField :label="t('Label')" name="label" required :error="errors.label">
<UInput
:model-value="field.label"
:placeholder="t('For example, Title')"
:disabled="disabled"
:color="errors.label ? 'error' : undefined"
required
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="emit('label', String($event))"
/>
</DirectoryField>
<SlugField
:label="t('Key')"
name="field-key"
:mode="keyMode"
:value="field.key"
:error="errors.key"
:disabled="disabled"
dense
:placeholder="STARTER_KEY"
:maxlength="BUILDER_LIMITS.fieldKeyMax"
:help="
t(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
"
:edit-label="t('Edit key')"
:reset-label="t('Reset to match the label')"
@update:value="emit('key', $event)"
@unlock="emit('unlock')"
@reset="emit('reset')"
/>
</div>
<FieldTypePicker
:model-value="type"
:compact="compact"
:disabled="disabled"
@update:model-value="emit('type', $event)"
/>
<div class="grid items-start gap-3.5" :class="gridClass">
<div class="flex min-w-0 flex-col gap-3.5">
<AppSwitch
:model-value="field.required"
:label="t('Required')"
:description="t('An entry can\'t be saved with this field empty.')"
:disabled="disabled"
:ui="SWITCH_UI"
@update:model-value="emit('required', Boolean($event))"
/>
<AppSwitch
:model-value="canUnique && field.unique"
:label="t('Unique values')"
:description="uniqueHelp"
:disabled="disabled || !canUnique"
:ui="SWITCH_UI"
@update:model-value="emit('unique', Boolean($event))"
/>
<AppSwitch
v-if="type === 'boolean'"
:model-value="field.defaultOn"
:label="t('On by default')"
:description="defaultOnHelp"
:disabled="disabled"
:ui="SWITCH_UI"
@update:model-value="emit('defaultOn', Boolean($event))"
/>
<DirectoryField
v-else
:label="t('Default value')"
name="default"
:error="errors.default"
:help="defaultHelp"
>
<USelect
v-if="type === 'select'"
:model-value="choiceValue"
:items="choiceItems"
:placeholder="t('None')"
:disabled="disabled || field.unique"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="onChoice"
/>
<UTextarea
v-else-if="type === 'richtext'"
:model-value="field.defaultValue"
:placeholder="t('No default')"
:disabled="disabled || field.unique"
:rows="3"
class="w-full"
@update:model-value="emit('default', String($event))"
/>
<UInput
v-else
:model-value="field.defaultValue"
:type="type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'"
:placeholder="t('No default')"
:disabled="disabled || field.unique"
:color="errors.default ? 'error' : undefined"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="emit('default', String($event))"
/>
</DirectoryField>
</div>
<div v-if="hasLimits" class="flex min-w-0 flex-col gap-3.5">
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
<UInput
:model-value="field.min"
:type="limitInput"
:placeholder="t('No minimum')"
:disabled="disabled"
:color="errors.min ? 'error' : undefined"
class="w-full"
:ui="{ base: 'no-spin h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="emit('limit', 'min', String($event))"
>
<template v-if="limitSuffix" #trailing>
<span class="text-xs text-muted">{{ limitSuffix }}</span>
</template>
</UInput>
</DirectoryField>
<DirectoryField
:label="limitLabels[1]"
name="max"
:error="errors.max"
:help="errors.min ? '' : maxHelp"
>
<UInput
:model-value="field.max"
:type="limitInput"
:placeholder="t('No maximum')"
:disabled="disabled"
:color="errors.max ? 'error' : undefined"
class="w-full"
:ui="{ base: 'no-spin h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="emit('limit', 'max', String($event))"
>
<template v-if="limitSuffix" #trailing>
<span class="text-xs text-muted">{{ limitSuffix }}</span>
</template>
</UInput>
</DirectoryField>
</div>
<div v-else class="flex min-w-0 flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
</div>
</div>
<ChoiceList
v-if="type === 'select'"
:options="field.options"
:row-errors="errors.choices"
:list-error="errors.options"
:disabled="disabled"
@add="emit('addChoice', $event)"
@remove="emit('removeChoice', $event)"
@rename="(index, text) => emit('renameChoice', index, text)"
@reorder="emit('reorderChoices', $event)"
/>
</div>
</template>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import FieldRow from '@/components/FieldRow.vue'
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
import { BUILDER_LIMITS, type BuilderField } from '@/lib/schemaBuilderModel'
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
const props = defineProps<{
fields: BuilderField[]
selectedUid: number | null
errors: Map<number, FieldErrors>
listError?: string
disabled: boolean
mobile: boolean
}>()
const emit = defineEmits<{
select: [uid: number]
add: []
remove: [uid: number]
move: [uid: number, delta: number]
reorder: [order: number[]]
}>()
const list = ref<HTMLElement | null>(null)
const focusedUid = ref<number | null>(null)
const reorder = useKeyboardReorder<BuilderField>({
items: () => props.fields,
name: (field) => field.label.trim() || t('Untitled field'),
commit: (next) =>
emit(
'reorder',
next.map((field) => field.uid),
),
root: () => list.value,
})
const rows = computed(() => reorder.shown.value)
const tabbableUid = computed(
() => focusedUid.value ?? props.selectedUid ?? props.fields[0]?.uid ?? null,
)
const full = computed(() => props.fields.length >= BUILDER_LIMITS.fieldsMax)
const countText = computed(() => t('{0} of {1}', [props.fields.length, BUILDER_LIMITS.fieldsMax]))
async function onMove(uid: number, delta: number) {
const action = delta < 0 ? 'up' : 'down'
emit('move', uid, delta)
await nextTick()
const row = list.value?.querySelector(`li[data-uid="${uid}"]`)
const same = row?.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)
const other = row?.querySelector<HTMLButtonElement>(
`[data-action="${action === 'up' ? 'down' : 'up'}"]`,
)
;(same && !same.disabled ? same : other)?.focus()
}
function onRowKeydown(event: KeyboardEvent) {
if (reorder.active.value || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) {
return
}
const target = event.target as HTMLElement
if (!target.matches('[data-field-row]')) {
return
}
const buttons = Array.from(list.value?.querySelectorAll<HTMLElement>('[data-field-row]') ?? [])
const next = buttons[buttons.indexOf(target) + (event.key === 'ArrowUp' ? -1 : 1)]
if (next) {
event.preventDefault()
next.focus()
}
}
function onFocusin(event: FocusEvent) {
const uid = (event.target as HTMLElement).closest('li[data-uid]')?.getAttribute('data-uid')
focusedUid.value = uid ? Number(uid) : null
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex h-7 items-center justify-between">
<h2 class="font-semibold text-highlighted">{{ t('Fields') }}</h2>
<span class="font-mono text-xs text-muted" data-testid="field-count">{{
countText
}}</span>
</div>
<div
role="status"
aria-live="polite"
data-testid="reorder-live"
:class="
reorder.active.value
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
: 'sr-only'
"
>
{{ reorder.announcement.value }}
</div>
<ul
v-if="rows.length"
ref="list"
class="m-0 flex list-none flex-col gap-2 p-0"
@keydown="onRowKeydown"
@focusin="onFocusin"
>
<FieldRow
v-for="(field, index) in rows"
:key="field.uid"
:field="field"
:index="index"
:total="rows.length"
:selected="!mobile && field.uid === selectedUid"
:invalid="errors.has(field.uid)"
:grabbed="reorder.grabbed.value === index"
:disabled="disabled"
:tabbable="field.uid === tabbableUid"
:show-actions="!mobile"
@select="emit('select', field.uid)"
@up="onMove(field.uid, -1)"
@down="onMove(field.uid, 1)"
@remove="emit('remove', field.uid)"
@grip-keydown="reorder.onKeydown($event, index)"
/>
</ul>
<div
v-else
ref="list"
data-testid="field-empty"
class="flex flex-col items-center gap-2 border border-dashed px-4 py-8 text-center"
:class="listError ? 'border-error' : 'border-accented'"
>
<UIcon name="i-lucide-rows-3" class="size-5 text-muted" />
<span class="font-semibold">{{ t('No fields yet') }}</span>
<span :class="listError ? 'text-error' : 'text-muted'">{{
listError || t('Add the first field.')
}}</span>
</div>
<UButton
icon="i-lucide-plus"
color="neutral"
variant="outline"
:disabled="disabled || full"
class="h-11 w-full justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
@click="emit('add')"
>
{{ t('Add field') }}
</UButton>
<slot />
</div>
</template>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import BuilderBadge from '@/components/BuilderBadge.vue'
import FieldActions from '@/components/FieldActions.vue'
import type { BuilderField } from '@/lib/schemaBuilderModel'
import { fieldIcon } from '@/lib/schemaFields'
const props = defineProps<{
field: BuilderField
index: number
total: number
selected: boolean
invalid: boolean
grabbed: boolean
disabled: boolean
tabbable: boolean
showActions: boolean
}>()
defineEmits<{
select: []
up: []
down: []
remove: []
'grip-keydown': [event: KeyboardEvent]
}>()
const name = computed(() => props.field.label.trim() || t('Untitled field'))
const key = computed(() => props.field.key || '–')
const rowClass = computed(() => [
props.invalid ? 'border-error' : props.grabbed ? 'border-primary' : 'border-default',
props.grabbed ? 'shadow-(--shadow-popover) outline-2 -outline-offset-1 outline-primary' : '',
props.selected ? 'bg-(--rc-sand) text-(--rc-on-sand)' : 'bg-default text-highlighted',
props.disabled ? 'opacity-60' : '',
])
</script>
<template>
<li
data-testid="field-row"
:data-uid="field.uid"
class="flex min-h-14 items-center gap-0.5 border py-1.5 pr-2 pl-1 max-md:min-h-16 max-md:gap-1"
:class="rowClass"
@click="$emit('select')"
>
<button
type="button"
data-grip
:data-handle="index"
:aria-label="t('Move {0}', [name])"
:aria-pressed="grabbed"
:disabled="disabled"
:tabindex="tabbable ? 0 : -1"
class="flex h-8 w-6 shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:outline-primary max-md:h-11 max-md:w-9"
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
@keydown="$emit('grip-keydown', $event)"
@click.stop
>
<UIcon name="i-lucide-grip-vertical" class="size-4" />
</button>
<button
type="button"
data-field-row
:data-uid="field.uid"
:aria-current="selected ? 'true' : undefined"
:disabled="disabled"
:tabindex="tabbable ? 0 : -1"
class="flex min-w-0 flex-1 items-center gap-2 self-stretch text-left focus-visible:outline-2 focus-visible:outline-primary"
@click.stop="$emit('select')"
>
<span
class="flex size-7 shrink-0 items-center justify-center text-highlighted"
:class="selected ? 'bg-default' : 'bg-elevated'"
>
<UIcon :name="fieldIcon(field.type)" class="size-4" />
</span>
<span class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium" data-testid="field-row-label">{{ name }}</span>
<span
class="truncate font-mono text-xs"
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
data-testid="field-row-key"
>{{ key }}</span
>
</span>
</button>
<span class="flex shrink-0 items-center gap-1" data-testid="field-row-badges">
<BuilderBadge kind="new" />
<BuilderBadge v-if="field.required" kind="required" :on-sand="selected" />
<BuilderBadge v-if="field.unique" kind="unique" :on-sand="selected" />
<UIcon
v-if="invalid"
name="i-lucide-circle-alert"
data-testid="field-row-alert"
class="size-4 text-error"
/>
</span>
<FieldActions
v-if="selected && showActions"
:first="index === 0"
:last="index === total - 1"
:disabled="disabled"
@up="$emit('up')"
@down="$emit('down')"
@remove="$emit('remove')"
/>
</li>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import FieldActions from '@/components/FieldActions.vue'
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
defineProps<{ title: string; first: boolean; last: boolean; disabled: boolean }>()
const emit = defineEmits<{ up: []; down: []; remove: []; closed: [] }>()
const open = defineModel<boolean>('open', { default: false })
const titleElement = ref<HTMLElement | null>(null)
function focusTitle(event: Event) {
event.preventDefault()
titleElement.value?.focus()
}
function returnFocus(event: Event) {
event.preventDefault()
emit('closed')
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
:title="title"
:description="title"
:content="{ onOpenAutoFocus: focusTitle, onCloseAutoFocus: returnFocus }"
:ui="BOTTOM_SHEET_UI"
>
<template #title>
<span
ref="titleElement"
tabindex="-1"
data-testid="field-sheet-title"
class="outline-none"
>{{ title }}</span
>
</template>
<template #body>
<FieldActions
:first="first"
:last="last"
:disabled="disabled"
class="self-end"
@up="emit('up')"
@down="emit('down')"
@remove="emit('remove')"
/>
<slot />
</template>
<template #footer>
<UButton class="h-11 flex-1 justify-center" @click="open = false">
{{ t('Done') }}
</UButton>
</template>
</USlideover>
</template>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { FIELD_TYPES, type FieldType } from '@/lib/schemaBuilderModel'
import { fieldIcon, fieldTypeLabel } from '@/lib/schemaFields'
defineProps<{ modelValue: FieldType; compact: boolean; disabled: boolean }>()
defineEmits<{ 'update:modelValue': [type: FieldType] }>()
function describe(type: FieldType) {
switch (type) {
case 'richtext':
return t('Long text with formatting, written in Markdown')
case 'number':
return t('Whole or decimal numbers')
case 'boolean':
return t('An on or off switch')
case 'date':
return t('A calendar day, without a time')
case 'select':
return t('One option from a list you define')
default:
return t('Short text, like a title or a name')
}
}
const items = computed(() =>
FIELD_TYPES.map((type) => ({ value: type, label: fieldTypeLabel(type) })),
)
</script>
<template>
<URadioGroup
:model-value="modelValue"
:items="items"
:disabled="disabled"
:legend="t('Type')"
variant="card"
indicator="hidden"
orientation="horizontal"
:ui="{
root: 'w-full',
legend: 'mb-1.5 font-medium text-highlighted',
fieldset: compact ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2',
item: 'group min-h-14 rounded-none border-accented p-2.5 text-highlighted has-data-[state=checked]:border-primary has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand) max-md:min-h-16',
wrapper: 'ms-0 w-full',
label: 'w-full text-base font-medium text-inherit',
}"
@update:model-value="$emit('update:modelValue', $event as FieldType)"
>
<template #label="{ item }">
<span class="flex items-start gap-2.5 text-left">
<UIcon :name="fieldIcon(item.value)" class="mt-0.5 size-4 shrink-0" />
<span class="flex min-w-0 flex-col">
<span class="font-medium">{{ item.label }}</span>
<span
class="text-xs/4 font-normal text-pretty text-muted group-has-data-[state=checked]:text-(--rc-on-sand)"
>{{ describe(item.value) }}</span
>
</span>
</span>
</template>
</URadioGroup>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ title: string; description: string }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<UAlert
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="title"
:description="description"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="$emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
</template>
+24 -1
View File
@@ -16,6 +16,9 @@ const props = withDefaults(
help?: string
manualHelp?: string
locked?: boolean
disabled?: boolean
dense?: boolean
resetInLabel?: boolean
lockedHelp?: string
editLabel?: string
resetLabel?: string
@@ -30,6 +33,9 @@ const props = withDefaults(
help: '',
manualHelp: '',
locked: false,
disabled: false,
dense: false,
resetInLabel: false,
lockedHelp: '',
editLabel: '',
resetLabel: '',
@@ -50,7 +56,7 @@ const emit = defineEmits<{
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
const inputUi = computed(() => ({
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
base: `h-11 font-mono text-base text-ellipsis ${props.dense ? 'md:h-8' : 'md:h-10'} md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
}))
const showReset = computed(
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
@@ -72,9 +78,23 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
<template>
<div class="flex min-w-0 flex-col gap-1.5">
<DirectoryField :label="label" :name="name" :error="error" :help="shownHelp">
<template v-if="resetInLabel && showReset" #hint>
<UButton
color="neutral"
variant="link"
icon="i-lucide-rotate-ccw"
:disabled="disabled"
class="gap-1 p-0 font-sans text-xs font-medium text-highlighted underline underline-offset-3 max-md:hidden"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('reset')"
>
{{ resetText }}
</UButton>
</template>
<UInput
:model-value="value"
:readonly="readonly"
:disabled="disabled"
:placeholder="placeholderText"
:maxlength="maxlength"
:data-autofocus="autofocus ? '' : undefined"
@@ -103,6 +123,7 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
color="neutral"
variant="ghost"
:aria-label="pencilLabel"
:disabled="disabled"
class="size-7 justify-center text-highlighted max-md:size-11"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('unlock')"
@@ -127,8 +148,10 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
v-if="showReset"
color="neutral"
variant="link"
:disabled="disabled"
icon="i-lucide-rotate-ccw"
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
:class="resetInLabel ? 'md:hidden' : ''"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('reset')"
>
@@ -0,0 +1,58 @@
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 ApiPreview from '../ApiPreview.vue'
import { mountInApp, text, unmountApp, holder } from './directoryKit'
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(unmountApp)
const JSON_TEXT = '{\n "id": 42\n}'
const toggle = () => document.body.querySelector<HTMLElement>('[data-testid="api-preview-toggle"]')!
describe('ApiPreview', () => {
it('is collapsed, then opens and closes with the toggle', async () => {
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
expect(toggle().getAttribute('aria-expanded')).toBe('false')
expect(document.body.querySelector('[data-testid="api-json"]')).toBeNull()
toggle().click()
await flushPromises()
expect(toggle().getAttribute('aria-expanded')).toBe('true')
expect(document.body.querySelector('[data-testid="api-json"]')!.textContent).toBe(JSON_TEXT)
expect(holder.wrapper!.findComponent(ApiPreview).emitted('update:open')![0]).toEqual([true])
})
it('shows the first route in the bar and falls back to events', async () => {
await mountInApp(ApiPreview, { keyValue: '', json: JSON_TEXT })
expect(text(document.body.querySelector('[data-testid="api-preview"]'))).toContain(
'GET /v1/content-type/events/entry',
)
})
it('copies the JSON and reads Copied', async () => {
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
document.body.querySelector<HTMLElement>('[data-testid="api-copy"]')!.click()
await flushPromises()
expect(writeText).toHaveBeenCalledWith(JSON_TEXT)
expect(text(document.body.querySelector('[data-testid="api-copy"]'))).toBe('Copied')
})
})
@@ -0,0 +1,176 @@
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 ChoiceList from '../ChoiceList.vue'
import { mountInApp, text, unmountApp, holder } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountApp)
function props(extra: Record<string, unknown> = {}) {
return { options: ['Talk', 'Workshop', 'Meetup'], disabled: false, ...extra }
}
function emitted(name: string) {
return holder.wrapper!.findComponent(ChoiceList).emitted(name) ?? []
}
const addInput = () =>
document.body.querySelector<HTMLInputElement>('input[aria-label="Add a choice"]')!
async function type(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
function addButton() {
return [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
(candidate) => text(candidate) === 'Add',
)!
}
describe('ChoiceList', () => {
it('shows the count, the rows and the help', async () => {
await mountInApp(ChoiceList, props())
expect(text(document.body.querySelector('[data-testid="choice-count"]'))).toBe('3 of 50')
expect(document.body.querySelectorAll('[data-testid="choice-row"]')).toHaveLength(3)
expect(text(document.body)).toContain('Add 1 to 50 choices.')
})
it('emits add from the button and from Enter, and ignores a blank draft', async () => {
await mountInApp(ChoiceList, props())
expect(addButton().disabled).toBe(true)
await type(addInput(), 'Panel')
addButton().click()
await flushPromises()
await type(addInput(), 'Social')
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
await type(addInput(), ' ')
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
expect(emitted('add')).toEqual([['Panel'], ['Social']])
expect(addInput().value).toBe(' ')
})
it('emits rename and remove for a row', async () => {
await mountInApp(ChoiceList, props())
await type(
document.body.querySelectorAll<HTMLInputElement>(
'[data-testid="choice-row"] input',
)[1]!,
'Seminar',
)
document.body.querySelector<HTMLElement>('[aria-label="Remove Talk"]')!.click()
await flushPromises()
expect(emitted('rename')).toEqual([[1, 'Seminar']])
expect(emitted('remove')).toEqual([[0]])
})
it('shows a row error with an alert icon and ties it to the input', async () => {
await mountInApp(ChoiceList, props({ rowErrors: { 1: 'Two choices are the same.' } }))
const row = document.body.querySelectorAll('[data-testid="choice-row"]')[1]!
expect(text(row.querySelector('[role="alert"]'))).toBe('Two choices are the same.')
expect(row.querySelector('[role="alert"] svg')).not.toBeNull()
const input = row.querySelector('input')!
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(input.getAttribute('aria-describedby')).toBe('choice-error-1')
})
it('shows a list error and disables Add at 50 choices', async () => {
await mountInApp(
ChoiceList,
props({ options: Array.from({ length: 50 }, (_, index) => `C${index}`) }),
)
await type(addInput(), 'more')
expect(addButton().disabled).toBe(true)
})
it('shows the list error', async () => {
await mountInApp(ChoiceList, props({ options: [], listError: 'Add at least one choice.' }))
expect(text(document.body.querySelector('p[role="alert"]'))).toBe(
'Add at least one choice.',
)
})
it('emits the new order on drop only', async () => {
await mountInApp(ChoiceList, props())
const grip = () => document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
const key = async (name: string) => {
grip().dispatchEvent(
new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }),
)
await flushPromises()
}
await key(' ')
await key('ArrowDown')
expect(emitted('reorder')).toHaveLength(0)
await key(' ')
expect(emitted('reorder')).toEqual([[['Workshop', 'Talk', 'Meetup']]])
})
it('keeps each error with its choice while a row is being moved', async () => {
await mountInApp(
ChoiceList,
props({
options: ['Talk', 'Talk', 'Meetup'],
rowErrors: { 1: 'Two choices are the same.' },
}),
)
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
grip.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
)
await flushPromises()
grip.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
const rows = [...document.body.querySelectorAll('[data-testid="choice-row"]')]
expect(rows.map((row) => text(row.querySelector('[role="alert"]')))).toEqual([
'Two choices are the same.',
'',
'',
])
})
it('emits the data index for rename and remove while a row is being moved', async () => {
await mountInApp(ChoiceList, props())
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
grip.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
)
await flushPromises()
grip.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
const removers = [...document.body.querySelectorAll<HTMLButtonElement>('button')].filter(
(candidate) => candidate.getAttribute('aria-label')?.startsWith('Remove '),
)
removers[0]!.click()
await flushPromises()
expect(emitted('remove')).toEqual([[1]])
})
})
@@ -0,0 +1,107 @@
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 FieldInspector from '../FieldInspector.vue'
import { createField } from '@/lib/schemaBuilderModel'
import { mountInApp, text, unmountApp, holder } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountApp)
function props(extra: Record<string, unknown> = {}) {
return {
field: createField(1, { label: 'Title', key: 'title' }),
errors: {},
disabled: false,
compact: false,
focusToken: 0,
...extra,
}
}
function emitted(name: string) {
return holder.wrapper!.findComponent(FieldInspector).emitted(name) ?? []
}
async function type(selector: string, value: string) {
const input = document.body.querySelector<HTMLInputElement>(selector)!
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
describe('FieldInspector', () => {
it('emits label, key, type, limit and default edits', async () => {
await mountInApp(
FieldInspector,
props({ field: createField(1, { label: 'T', key: 't', keyMode: 'manual' }) }),
)
await type('input[name="label"]', 'Headline')
await type('input[name="field-key"]', 'headline')
await type('input[name="max"]', '80')
await type('input[name="default"]', 'Draft')
document.body.querySelector<HTMLElement>('[role="radio"][value="number"]')!.click()
await flushPromises()
expect(emitted('label')).toEqual([['Headline']])
expect(emitted('key')).toEqual([['headline']])
expect(emitted('limit')).toEqual([['max', '80']])
expect(emitted('default')).toEqual([['Draft']])
expect(emitted('type')).toEqual([['number']])
})
it('emits the Required switch', async () => {
await mountInApp(FieldInspector, props())
const label = [...document.body.querySelectorAll('label')].find((node) =>
text(node).startsWith('Required'),
)!
document.getElementById(label.getAttribute('for')!)!.click()
await flushPromises()
expect(emitted('required')).toEqual([[true]])
})
it('dims and disables every control while disabled', async () => {
await mountInApp(FieldInspector, props({ disabled: true }))
const root = document.body.querySelector('[data-testid="field-inspector"]')!
expect(root.className).toContain('opacity-60')
expect(
[...root.querySelectorAll<HTMLInputElement>('input')].every((input) => input.disabled),
).toBe(true)
})
it('shows the errors under their controls', async () => {
await mountInApp(
FieldInspector,
props({
field: createField(1, { type: 'number', min: '6', max: '5', defaultValue: '0' }),
errors: {
label: 'Give the field a label.',
key: 'Bad key.',
min: 'Order.',
default: 'Outside.',
},
}),
)
const alerts = [...document.body.querySelectorAll('[role="alert"]')].map(text)
expect(alerts).toEqual(['Give the field a label.', 'Bad key.', 'Outside.', 'Order.'])
})
it('lays the controls out in one column when compact', async () => {
await mountInApp(FieldInspector, props({ compact: true }))
expect(document.body.querySelector('.grid-cols-1')).not.toBeNull()
expect(document.body.querySelector('.grid-cols-2')).not.toBeNull()
})
})
@@ -0,0 +1,141 @@
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 FieldList from '../FieldList.vue'
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
import { mountInApp, text, unmountApp, holder } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountApp)
const FIELDS: BuilderField[] = [
createField(1, { label: 'Title', key: 'title' }),
createField(2, { label: 'Starts on', key: 'starts_on', type: 'date' }),
createField(3, { label: 'Ends on', key: 'ends_on', type: 'date' }),
]
function props(extra: Record<string, unknown> = {}) {
return {
fields: FIELDS,
selectedUid: 2,
errors: new Map(),
disabled: false,
mobile: false,
...extra,
}
}
function emitted(name: string) {
return holder.wrapper!.findComponent(FieldList).emitted(name) ?? []
}
function grip(label: string) {
return document.body.querySelector<HTMLElement>(`[aria-label="Move ${label}"]`)!
}
async function press(target: Element, key: string) {
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
await flushPromises()
}
function labels() {
return [...document.body.querySelectorAll('[data-testid="field-row-label"]')].map(text)
}
describe('FieldList', () => {
it('lists the rows and counts them against 50', async () => {
await mountInApp(FieldList, props())
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('3 of 50')
})
it('emits select, add, move and remove', async () => {
await mountInApp(FieldList, props())
document.body.querySelector<HTMLElement>('[data-field-row][data-uid="3"]')!.click()
;(document.body.querySelector('[data-action="up"]') as HTMLElement).click()
;(document.body.querySelector('[data-action="remove"]') as HTMLElement).click()
const add = [...document.body.querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Add field',
)!
add.click()
await flushPromises()
expect(emitted('select')).toEqual([[3]])
expect(emitted('move')).toEqual([[2, -1]])
expect(emitted('remove')).toEqual([[2]])
expect(emitted('add')).toHaveLength(1)
})
it('disables Add field at 50 rows and when disabled', async () => {
const many = Array.from({ length: 50 }, (_, index) =>
createField(index + 1, { label: 'F' }),
)
await mountInApp(FieldList, props({ fields: many, selectedUid: 1 }))
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
(candidate) => text(candidate) === 'Add field',
)!
expect(add.disabled).toBe(true)
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('50 of 50')
})
it('shows the empty state with the error message in the error colour', async () => {
await mountInApp(FieldList, props({ fields: [], selectedUid: null, listError: 'Nope.' }))
const empty = document.body.querySelector('[data-testid="field-empty"]')!
expect(text(empty)).toBe('No fields yet Nope.')
expect(empty.querySelector('.text-error')).not.toBeNull()
})
it('emits the new order once, on drop, and nothing on cancel', async () => {
await mountInApp(FieldList, props())
await press(grip('Starts on'), ' ')
await press(grip('Starts on'), 'ArrowUp')
await press(grip('Starts on'), 'Escape')
expect(emitted('reorder')).toHaveLength(0)
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
await press(grip('Starts on'), ' ')
await press(grip('Starts on'), 'ArrowUp')
expect(emitted('reorder')).toHaveLength(0)
await press(grip('Starts on'), ' ')
expect(emitted('reorder')).toEqual([[[2, 1, 3]]])
})
it('moves the focus between rows with the arrow keys', async () => {
await mountInApp(FieldList, props())
const rows = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')]
rows[0]!.focus()
await press(rows[0]!, 'ArrowDown')
expect(document.activeElement).toBe(rows[1])
await press(rows[1]!, 'ArrowUp')
expect(document.activeElement).toBe(rows[0])
})
it('keeps one roving tab stop for the rows', async () => {
await mountInApp(FieldList, props())
const stops = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')].filter(
(row) => row.tabIndex === 0,
)
expect(stops.map((row) => row.getAttribute('data-uid'))).toEqual(['2'])
})
it('hides the row actions on mobile', async () => {
await mountInApp(FieldList, props({ mobile: true }))
expect(document.body.querySelector('[data-action]')).toBeNull()
})
})
@@ -97,3 +97,61 @@ describe('listBuiltinTypes', () => {
expect(failure.status).toBe(status)
})
})
describe('createContentType', () => {
const payload = {
key: 'recipes',
name: 'Recipes',
fields: [
{
key: 'title',
label: 'Title',
type: 'text',
required: true,
unique: false,
default: null,
min: null,
max: null,
options: [],
},
],
}
it('posts the JSON body to /v1/content-type and returns the created type', async () => {
answer(201, recipes)
await expect(useContentTypes().createContentType(payload)).resolves.toEqual(recipes)
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('/v1/content-type')
expect(init.method).toBe('POST')
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
expect(JSON.parse(init.body)).toEqual(payload)
})
it.each([
[400, 'invalid_content_type_key'],
[409, 'content_type_key_taken'],
[422, 'invalid_field'],
[403, 'Only owners and admins can do this'],
[500, 'boom'],
])('rejects %i with its plain-text body', async (status, body) => {
answer(status, body)
const failure = await useContentTypes()
.createContentType(payload)
.catch((error) => error)
expect(failure).toBeInstanceOf(ApiError)
expect(failure.status).toBe(status)
expect(failure.body).toBe(body)
})
it('lets a network failure through', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await expect(useContentTypes().createContentType(payload)).rejects.toBeInstanceOf(
NetworkError,
)
})
})
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError } from '@/composables/useApi'
import { useContentTypes } from '@/composables/useContentTypes'
import { useSchemaGate, type SchemaGateScope } from '@/composables/useSchemaGate'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
vi.mock('@/composables/useContentTypes', () => ({ useContentTypes: vi.fn() }))
const listContentTypes = vi.fn()
const listBuiltinTypes = vi.fn()
beforeEach(() => {
setActivePinia(createPinia())
listContentTypes.mockReset()
listBuiltinTypes.mockReset()
vi.mocked(useContentTypes).mockReturnValue({ listContentTypes, listBuiltinTypes })
const session = useSessionStore()
session.$patch({ status: 'ready', session: { role: 'owner' } })
})
async function gate(scope: SchemaGateScope) {
const store = useContentTypesStore()
await store.load({ builtin: true })
return useSchemaGate(scope)
}
describe('schema gate forbidden', () => {
it.each([
['custom', true],
['builtin', false],
['all', true],
] as const)('scope %s with custom 403 is %s', async (scope, expected) => {
listContentTypes.mockRejectedValue(new ApiError(403))
listBuiltinTypes.mockResolvedValue([])
expect((await gate(scope)).forbidden.value).toBe(expected)
})
it.each([
['custom', false],
['builtin', true],
['all', true],
] as const)('scope %s with builtin 403 is %s', async (scope, expected) => {
listContentTypes.mockResolvedValue([])
listBuiltinTypes.mockRejectedValue(new ApiError(403))
expect((await gate(scope)).forbidden.value).toBe(expected)
})
it('is forbidden for a session that cannot manage content, whatever the scope', async () => {
listContentTypes.mockResolvedValue([])
listBuiltinTypes.mockResolvedValue([])
useSessionStore().$patch({ session: { role: 'user' } })
expect((await gate('custom')).forbidden.value).toBe(true)
})
})
+10 -1
View File
@@ -1,4 +1,6 @@
import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest'
import type { ContentTypePayload } from '@/lib/schemaBuilderPayload'
export interface BuiltinField {
key: string
@@ -48,5 +50,12 @@ export function useContentTypes() {
return requestList<BuiltinType>('/v1/builtin-type')
}
return { listContentTypes, listBuiltinTypes }
function createContentType(payload: ContentTypePayload): Promise<ContentType> {
return request<ContentType>('/v1/content-type', {
...jsonInit('POST', payload),
...options,
})
}
return { listContentTypes, listBuiltinTypes, createContentType }
}
+10
View File
@@ -0,0 +1,10 @@
import { computed } from 'vue'
import { t } from '@/i18n'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
export function useJsonCopy() {
const { copied, copy } = useCopyToClipboard()
const label = computed(() => (copied.value ? t('Copied') : t('Copy JSON')))
const icon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
return { copied, copy, label, icon }
}
+125
View File
@@ -0,0 +1,125 @@
import { computed, nextTick, ref, shallowRef } from 'vue'
import { t } from '@/i18n'
import { moveItem } from '@/lib/arrayMove'
interface KeyboardReorderOptions<T> {
items: () => readonly T[]
name: (item: T) => string
commit: (next: T[]) => void
root: () => HTMLElement | null
}
export function useKeyboardReorder<T>(options: KeyboardReorderOptions<T>) {
const working = shallowRef<T[] | null>(null)
const grabbed = ref<number | null>(null)
const origin = ref(0)
const announcement = ref('')
const shown = computed<readonly T[]>(() => working.value ?? options.items())
const active = computed(() => grabbed.value !== null)
function focusHandle(index: number) {
nextTick(() =>
options.root()?.querySelector<HTMLElement>(`[data-handle="${index}"]`)?.focus(),
)
}
function movingText(item: T, position: number, total: number) {
return t(
'Moving {0}. Position {1} of {2}. Press Up or Down to move, Space to drop, Esc to cancel.',
[options.name(item), position, total],
)
}
function grab(index: number) {
const items = [...options.items()]
const item = items[index]
if (item === undefined) {
return
}
working.value = items
grabbed.value = index
origin.value = index
announcement.value = movingText(item, index + 1, items.length)
}
function move(delta: number) {
const current = grabbed.value
const items = working.value
if (current === null || !items) {
return
}
const target = current + delta
if (target < 0 || target >= items.length) {
return
}
working.value = moveItem(items, current, target)
grabbed.value = target
announcement.value = movingText(items[current]!, target + 1, items.length)
focusHandle(target)
}
function release() {
working.value = null
grabbed.value = null
}
function drop() {
const current = grabbed.value
const items = working.value
if (current === null || !items) {
return
}
announcement.value = t('{0} dropped. Position {1} of {2}.', [
options.name(items[current]!),
current + 1,
items.length,
])
release()
if (current !== origin.value) {
options.commit(items)
}
focusHandle(current)
}
function cancel() {
const current = grabbed.value
const items = working.value
if (current === null || !items) {
return
}
const index = origin.value
announcement.value = t('Reorder cancelled. {0} is back at position {1} of {2}.', [
options.name(items[current]!),
index + 1,
items.length,
])
release()
focusHandle(index)
}
function onKeydown(event: KeyboardEvent, index: number) {
if (grabbed.value === null) {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
grab(index)
}
return
}
const actions: Record<string, () => void> = {
ArrowUp: () => move(-1),
ArrowDown: () => move(1),
' ': drop,
Enter: drop,
Escape: cancel,
}
const action = actions[event.key]
if (action) {
event.preventDefault()
event.stopPropagation()
action()
}
}
return { shown, grabbed, active, announcement, onKeydown }
}
+473
View File
@@ -0,0 +1,473 @@
import { computed, ref } from 'vue'
import { useOnline } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
import { moveItem } from '@/lib/arrayMove'
import { SCHEMA_PATH } from '@/lib/routePaths'
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
import {
BUILDER_LIMITS,
blankForm,
changeFieldType,
clearStaleDefault,
createField,
type BuilderField,
type BuilderForm,
type BuilderStatus,
type FieldType,
} from '@/lib/schemaBuilderModel'
import { buildPayload } from '@/lib/schemaBuilderPayload'
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
import type { FieldTouch, TypeTouch } from '@/lib/schemaBuilderTouch'
import {
countErrors,
countProblems,
validateForm,
type FieldErrors,
type FormErrors,
} from '@/lib/schemaBuilderValidation'
import { useContentTypesStore } from '@/stores/contentTypes'
const KEY_TAKEN_BODY = 'content_type_key_taken'
const INVALID_KEY_BODY = 'invalid_content_type_key'
const INVALID_FIELD_BODY = 'invalid_field'
const TOAST_ID = 'content-type-saved'
export type SaveFailure = 'none' | 'failed' | 'invalid' | 'forbidden'
export type SaveState = 'editing' | 'saving' | 'saved' | 'failed'
function signature(form: BuilderForm) {
return JSON.stringify({
name: form.name,
key: form.key,
keyMode: form.keyMode,
fields: form.fields.map((field) => ({ ...field, uid: 0 })),
})
}
export function useSchemaBuilder() {
const router = useRouter()
const store = useContentTypesStore()
const toast = useLocalizedToast()
const online = useOnline()
let nextUid = 1
const newUid = () => nextUid++
const form = ref<BuilderForm>(blankForm(newUid()))
let initialSignature = signature(form.value)
const selectedUid = ref<number | null>(form.value.fields[0]!.uid)
const touched = ref(new Set<string>())
const submitted = ref(false)
const saveState = ref<SaveState>('editing')
const failure = ref<SaveFailure>('none')
const keyServerError = ref('')
const keyFocusToken = ref(0)
const labelFocusToken = ref(0)
const saving = computed(() => saveState.value === 'saving')
const dirty = computed(
() => saveState.value !== 'saved' && signature(form.value) !== initialSignature,
)
const errors = computed(() => validateForm(form.value))
const selected = computed(
() => form.value.fields.find((field) => field.uid === selectedUid.value) ?? null,
)
function touchKey(key: string) {
if (!touched.value.has(key)) {
touched.value = new Set(touched.value).add(key)
}
}
function touch(name: TypeTouch) {
touchKey(typeTouchKey(name))
}
function touchField(uid: number, name: FieldTouch) {
touchKey(fieldTouchKey(uid, name))
}
function isShown(key: string) {
return submitted.value || touched.value.has(key)
}
function shownFieldErrors(uid: number, all: FieldErrors) {
const pick: Record<string, unknown> = {}
for (const [errorKey, touchName] of FIELD_ERROR_TOUCH) {
if (all[errorKey] && isShown(fieldTouchKey(uid, touchName))) {
pick[errorKey] = all[errorKey]
}
}
return pick as FieldErrors
}
const shown = computed<FormErrors>(() => {
const type = { ...errors.value.type }
if (!isShown(typeTouchKey('name'))) delete type.name
if (!isShown(typeTouchKey('key'))) delete type.key
if (!isShown(typeTouchKey('fields'))) delete type.fields
if (keyServerError.value) type.key = keyServerError.value
const fields = new Map<number, FieldErrors>()
for (const [uid, all] of errors.value.fields) {
const pick = shownFieldErrors(uid, all)
if (Object.keys(pick).length > 0) {
fields.set(uid, pick)
}
}
return { type, fields }
})
const hasServerKeyError = computed(
() => keyServerError.value !== '' && !errors.value.type.key,
)
const problems = computed(
() => countProblems(errors.value) + (hasServerKeyError.value ? 1 : 0),
)
const hasHidden = computed(() => countErrors(errors.value) > countErrors(shown.value))
const engaged = computed(() => dirty.value || touched.value.size > 0 || submitted.value)
const status = computed<BuilderStatus>(() => {
if (saving.value) return 'saving'
if (!online.value) return 'offline'
if (failure.value === 'failed') return 'failed'
if (problems.value > 0 && engaged.value) return 'problems'
return dirty.value ? 'dirty' : 'none'
})
const canSave = computed(
() => !saving.value && online.value && dirty.value && problems.value === 0,
)
const canReveal = computed(
() =>
!saving.value && online.value && dirty.value && hasHidden.value && !submitted.value,
)
const canDiscard = computed(
() =>
!saving.value &&
(dirty.value ||
touched.value.size > 0 ||
submitted.value ||
keyServerError.value !== ''),
)
function clearFailure() {
if (failure.value !== 'none' && failure.value !== 'forbidden') {
failure.value = 'none'
}
}
function edited() {
if (saveState.value === 'saved' || saveState.value === 'failed') {
saveState.value = 'editing'
}
clearFailure()
}
function otherKeys(uid: number) {
return form.value.fields.filter((field) => field.uid !== uid).map((field) => field.key)
}
function setName(name: string) {
form.value.name = name
touch('name')
if (form.value.keyMode === 'auto') {
form.value.key = deriveTypeKey(name)
keyServerError.value = ''
}
edited()
}
function setKey(key: string) {
form.value.key = key
form.value.keyMode = 'manual'
keyServerError.value = ''
touch('key')
edited()
}
function unlockKey() {
form.value.keyMode = 'manual'
}
function resetKey() {
form.value.keyMode = 'auto'
form.value.key = deriveTypeKey(form.value.name)
keyServerError.value = ''
edited()
}
function indexOf(uid: number) {
return form.value.fields.findIndex((field) => field.uid === uid)
}
function replaceField(uid: number, next: BuilderField) {
const index = indexOf(uid)
if (index !== -1) {
form.value.fields.splice(index, 1, next)
}
}
function patchField(uid: number, patch: Partial<BuilderField>, touchName?: FieldTouch) {
const field = form.value.fields[indexOf(uid)]
if (!field) {
return
}
replaceField(uid, clearStaleDefault({ ...field, ...patch }))
if (touchName) {
touchField(uid, touchName)
}
edited()
}
function setLabel(uid: number, label: string) {
const field = form.value.fields[indexOf(uid)]
if (!field) {
return
}
const patch: Partial<BuilderField> = { label }
if (field.keyMode === 'auto') {
patch.key = deriveFieldKey(label, otherKeys(uid))
}
patchField(uid, patch, 'label')
}
function setFieldKey(uid: number, key: string) {
patchField(uid, { key, keyMode: 'manual' }, 'key')
}
function unlockFieldKey(uid: number) {
patchField(uid, { keyMode: 'manual' })
}
function resetFieldKey(uid: number) {
const field = form.value.fields[indexOf(uid)]
if (field) {
patchField(uid, { keyMode: 'auto', key: deriveFieldKey(field.label, otherKeys(uid)) })
}
}
function setFieldType(uid: number, type: FieldType) {
const field = form.value.fields[indexOf(uid)]
if (field) {
replaceField(uid, changeFieldType(field, type))
edited()
}
}
function setLimit(uid: number, which: 'min' | 'max', value: string) {
patchField(uid, { [which]: value }, which)
}
function setDefault(uid: number, value: string) {
patchField(uid, { defaultValue: value }, 'default')
}
function selectField(uid: number) {
selectedUid.value = uid
}
function addField() {
if (form.value.fields.length >= BUILDER_LIMITS.fieldsMax) {
return
}
const field = createField(newUid())
form.value.fields.push(field)
selectedUid.value = field.uid
labelFocusToken.value++
edited()
}
function removeField(uid: number) {
const index = indexOf(uid)
if (index === -1) {
return
}
form.value.fields.splice(index, 1)
if (selectedUid.value === uid) {
const next = form.value.fields[index] ?? form.value.fields[index - 1]
selectedUid.value = next?.uid ?? null
}
if (form.value.fields.length === 0) {
touch('fields')
}
edited()
}
function moveField(uid: number, delta: number) {
const index = indexOf(uid)
form.value.fields = moveItem(form.value.fields, index, index + delta)
edited()
}
function reorderFields(order: number[]) {
const byUid = new Map(form.value.fields.map((field) => [field.uid, field]))
form.value.fields = order.map((uid) => byUid.get(uid)!)
edited()
}
function setOptions(uid: number, options: string[]) {
patchField(uid, { options }, 'choices')
}
function addChoice(uid: number, text: string) {
const field = form.value.fields[indexOf(uid)]
if (!field || text.trim() === '' || field.options.length >= BUILDER_LIMITS.choicesMax) {
return
}
setOptions(uid, [...field.options, text])
}
function removeChoice(uid: number, index: number) {
const field = form.value.fields[indexOf(uid)]
if (field) {
setOptions(
uid,
field.options.filter((_, position) => position !== index),
)
}
}
function renameChoice(uid: number, index: number, text: string) {
const field = form.value.fields[indexOf(uid)]
if (field) {
setOptions(
uid,
field.options.map((option, position) => (position === index ? text : option)),
)
}
}
function reorderChoices(uid: number, options: string[]) {
setOptions(uid, options)
}
function discard() {
form.value = blankForm(newUid())
initialSignature = signature(form.value)
selectedUid.value = form.value.fields[0]!.uid
touched.value = new Set()
submitted.value = false
keyServerError.value = ''
failure.value = 'none'
saveState.value = 'editing'
}
function onKeyTaken() {
form.value.keyMode = 'manual'
keyServerError.value = t(
'The key "{0}" is already used by another content type. Choose a different key.',
[form.value.key],
)
keyFocusToken.value++
}
function onInvalidKey() {
form.value.keyMode = 'manual'
keyServerError.value = t('Use 2 to 40 lowercase letters, digits or hyphens.')
keyFocusToken.value++
}
function onRefused(error: unknown) {
if (error instanceof ApiError && error.status === 401) {
return
}
const body = error instanceof ApiError ? error.body : undefined
if (error instanceof ApiError && error.status === 409 && body === KEY_TAKEN_BODY) {
onKeyTaken()
} else if (error instanceof ApiError && error.status === 400 && body === INVALID_KEY_BODY) {
onInvalidKey()
} else if (
error instanceof ApiError &&
error.status === 422 &&
body === INVALID_FIELD_BODY
) {
failure.value = 'invalid'
} else if (error instanceof ApiError && error.status === 403) {
failure.value = 'forbidden'
} else {
failure.value = 'failed'
}
}
function announceSaved(name: string, count: number) {
toast.add(() => ({
id: TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Content type saved'),
description:
count === 1
? t('{0} now has 1 field.', [name])
: t('{0} now has {1} fields.', [name, count]),
}))
}
async function save() {
if (!canSave.value && !canReveal.value) {
return
}
submitted.value = true
if (!canSave.value) {
return
}
failure.value = 'none'
saveState.value = 'saving'
let created: ContentType
try {
created = await useContentTypes().createContentType(buildPayload(form.value))
} catch (error) {
saveState.value = 'failed'
onRefused(error)
return
}
saveState.value = 'saved'
store.add(created)
announceSaved(created.name, created.fields.length)
await router.replace(SCHEMA_PATH).catch(() => undefined)
}
return {
form,
selectedUid,
selected,
dirty,
shown,
problems,
status,
canSave,
canReveal,
canDiscard,
saving,
failure,
keyFocusToken,
labelFocusToken,
online,
setName,
setKey,
unlockKey,
resetKey,
setLabel,
setFieldKey,
unlockFieldKey,
resetFieldKey,
setFieldType,
setLimit,
setDefault,
patchField,
selectField,
addField,
removeField,
moveField,
reorderFields,
addChoice,
removeChoice,
renameChoice,
reorderChoices,
touch,
discard,
save,
}
}
+10 -4
View File
@@ -3,20 +3,26 @@ import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
export type SchemaGateScope = 'all' | 'builtin'
export type SchemaGateScope = 'all' | 'builtin' | 'custom'
export function useSchemaGate(scope: SchemaGateScope) {
const session = useSessionStore()
const store = useContentTypesStore()
const skeleton = useDelayedSkeleton()
const withCustom = scope === 'all'
const withCustom = scope !== 'builtin'
const withBuiltin = scope !== 'custom'
const sessionReady = computed(() => session.status === 'ready')
const forbidden = computed(
() => (sessionReady.value && !session.canManageContent) || store.forbidden,
() =>
(sessionReady.value && !session.canManageContent) ||
(withCustom && store.status === 'forbidden') ||
(withBuiltin && store.builtinStatus === 'forbidden'),
)
const loading = computed(
() => !forbidden.value && ((withCustom && store.customLoading) || store.builtinLoading),
() =>
!forbidden.value &&
((withCustom && store.customLoading) || (withBuiltin && store.builtinLoading)),
)
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
const customFailed = computed(() => withCustom && store.error)
+111
View File
@@ -2298,6 +2298,117 @@ export const messages = {
'Il a peut-être été supprimé, ou l’adresse est mal saisie.',
'GET /v1/builtin-type failed. Check your connection and try again.':
'GET /v1/builtin-type a échoué. Vérifiez votre connexion et réessayez.',
'{0} now has 1 field.': '{0} a maintenant 1 champ.',
'{0} now has {1} fields.': '{0} a maintenant {1} champs.',
'{0} problems to fix': '{0} problèmes à corriger',
'1 problem to fix': '1 problème à corriger',
'A calendar day, without a time': 'Un jour du calendrier, sans heure',
'A content type can have up to 50 fields.': 'Un type de contenu peut avoir jusqu’à 50 champs.',
'A content type needs at least one field.': 'Un type de contenu a besoin d’au moins un champ.',
Add: 'Ajouter',
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.':
'Ajoutez de 1 à 50 choix. Chacun doit avoir un nom différent. Supprimer un choix utilisé par des entrées sera refusé à l’enregistrement.',
'Add a choice': 'Ajouter un choix',
'Add a field, then choose its type and rules here.':
'Ajoutez un champ, puis choisissez ici son type et ses règles.',
'Add at least one choice.': 'Ajoutez au moins un choix.',
'Add field': 'Ajouter un champ',
'Add the first field.': 'Ajoutez le premier champ.',
"A field with unique values can't have a default.":
'Un champ à valeurs uniques ne peut pas avoir de valeur par défaut.',
"An entry can't be saved with this field empty.":
'Une entrée ne peut pas être enregistrée si ce champ est vide.',
'An on or off switch': 'Un interrupteur activé ou désactivé',
'Another field already uses this key.': 'Un autre champ utilise déjà cette clé.',
'API preview': 'Aperçu de l’API',
"A sample entry. Values come from each field's type and default.":
'Un exemple d’entrée. Les valeurs viennent du type et de la valeur par défaut de chaque champ.',
characters: 'caractères',
'Check the fields marked in the list.': 'Vérifiez les champs marqués dans la liste.',
'Choice {0}': 'Choix {0}',
Choices: 'Choix',
"Choices can't be blank.": 'Les choix ne peuvent pas être vides.',
'Content type saved': 'Type de contenu enregistré',
"Couldn't save the content type": 'Impossible d’enregistrer le type de contenu',
'Counted in characters. Entries show a counter.':
'Compté en caractères. Les entrées affichent un compteur.',
'Default value': 'Valeur par défaut',
Discard: 'Abandonner',
'Each entry needs a different value.': 'Chaque entrée doit avoir une valeur différente.',
'Earliest date': 'Date la plus proche',
"Earliest date can't be after the latest date.":
'La date la plus proche ne peut pas être après la date la plus éloignée.',
'Enter a number.': 'Saisissez un nombre.',
'Entries outside the range are refused.': 'Les entrées hors de cette plage sont refusées.',
'Field: {0}': 'Champ : {0}',
'Field settings': 'Réglages du champ',
'For example, Events': 'Par exemple, Événements',
'For example, Title': 'Par exemple, Titre',
'Give the content type a name.': 'Donnez un nom au type de contenu.',
'Use 100 characters or fewer.': 'Utilisez 100 caractères maximum.',
'Give the field a label.': 'Donnez un libellé au champ.',
'Highest value': 'Valeur la plus haute',
'Latest date': 'Date la plus éloignée',
Limits: 'Limites',
'Limits come from the choices below.': 'Les limites viennent des choix ci-dessous.',
Longest: 'Plus long',
'Long text with formatting, written in Markdown':
'Texte long avec mise en forme, écrit en Markdown',
'Lowest value': 'Valeur la plus basse',
"Lowest value can't be above the highest value.":
'La valeur la plus basse ne peut pas dépasser la valeur la plus haute.',
'Move {0}': 'Déplacer {0}',
'Move down': 'Descendre',
'Move up': 'Monter',
'Moving {0}. Position {1} of {2}. Press Up or Down to move, Space to drop, Esc to cancel.':
'Déplacement de {0}. Position {1} sur {2}. Appuyez sur Haut ou Bas pour déplacer, sur Espace pour déposer, sur Échap pour annuler.',
New: 'Nouveau',
'New entries start with this switch off when the field is left out.':
'Les nouvelles entrées démarrent avec cet interrupteur désactivé quand le champ est omis.',
'New entries start with this switch on when the field is left out.':
'Les nouvelles entrées démarrent avec cet interrupteur activé quand le champ est omis.',
'No default': 'Aucune valeur par défaut',
'No fields yet': 'Aucun champ pour l’instant',
'No maximum': 'Aucun maximum',
'No minimum': 'Aucun minimum',
'Not available for rich text.': 'Indisponible pour le texte enrichi.',
'Not available for yes or no fields.': 'Indisponible pour les champs oui ou non.',
'On by default': 'Activé par défaut',
'One option from a list you define': 'Une option parmi une liste que vous définissez',
'Reset to match the label': 'Rétablir d’après le libellé',
Shortest: 'Plus court',
"Shortest can't be longer than longest.":
'Le plus court ne peut pas être plus long que le plus long.',
'Short text, like a title or a name': 'Texte court, comme un titre ou un nom',
'Something went wrong on our side. Your changes are still here. Try again in a moment.':
'Un problème est survenu de notre côté. Vos modifications sont toujours là. Réessayez dans un instant.',
'Start with a letter. Use lowercase letters, digits and underscores.':
'Commencez par une lettre. Utilisez des minuscules, des chiffres et des tirets bas.',
'The default value is outside the limits.': 'La valeur par défaut est hors des limites.',
'The key "{0}" is already used by another content type. Choose a different key.':
'La clé « {0} » est déjà utilisée par un autre type de contenu. Choisissez une autre clé.',
'The key "{0}" is reserved. Choose a different key.':
'La clé « {0} » est réservée. Choisissez une autre clé.',
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.':
'Le nom utilisé dans l’API. Commence par une lettre. Minuscules, chiffres et tirets bas.',
'Two choices are the same.': 'Deux choix sont identiques.',
Unique: 'Unique',
'Unique values': 'Valeurs uniques',
'Untitled field': 'Champ sans titre',
'Updates as you edit. Fields you add or remove here appear after you save.':
'Se met à jour pendant la saisie. Les champs que vous ajoutez ou supprimez ici apparaissent après l’enregistrement.',
'Use 2 to 40 lowercase letters, digits or hyphens.':
'Utilisez de 2 à 40 minuscules, chiffres ou tirets.',
'Use a whole number, 0 or more.': 'Utilisez un nombre entier, 0 ou plus.',
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.":
'Utilisée dans les adresses et l’API. Minuscules, chiffres et tirets. Elle ne peut plus changer après l’enregistrement.',
'Used when an entry is created and this field is left out.':
'Utilisée quand une entrée est créée et que ce champ est omis.',
'Where entries of this type live': 'Où se trouvent les entrées de ce type',
'Whole or decimal numbers': 'Nombres entiers ou décimaux',
'Yes or no fields have no limits.': 'Les champs oui ou non n’ont pas de limites.',
"You're offline. Changes will be saved when you reconnect.":
'Vous êtes hors ligne. Les modifications seront enregistrées à la reconnexion.',
} as const
export type MessageKey = keyof typeof messages
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { moveItem } from '@/lib/arrayMove'
describe('moveItem', () => {
it('moves an item forward and backward without touching the input', () => {
const items = ['a', 'b', 'c']
expect(moveItem(items, 0, 2)).toEqual(['b', 'c', 'a'])
expect(moveItem(items, 2, 0)).toEqual(['c', 'a', 'b'])
expect(items).toEqual(['a', 'b', 'c'])
})
it.each([
[1, 1],
[-1, 0],
[0, -1],
[3, 0],
[0, 3],
])('returns the same array for %i to %i', (from, to) => {
const items = ['a', 'b', 'c']
expect(moveItem(items, from, to)).toBe(items)
})
})
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
describe('type key from the name', () => {
it.each([
['Events', 'events'],
['Press mentions', 'press-mentions'],
['Café & Bar', 'cafe-bar'],
[' Job openings! ', 'job-openings'],
['2026 Awards', '2026-awards'],
['!!!', ''],
['', ''],
])('%j gives %j', (name, key) => {
expect(deriveTypeKey(name)).toBe(key)
})
it('keeps the first 40 characters of a long name', () => {
const name = 'abcdefghij'.repeat(6)
expect(deriveTypeKey(name)).toBe(name.slice(0, 40))
})
it('drops a hyphen left at the end by the cut', () => {
const name = `${'a'.repeat(39)} bbbbbbbbbb`
expect(deriveTypeKey(name)).toBe('a'.repeat(39))
})
})
describe('field key from the label', () => {
it.each([
['Title', 'title'],
['Starts on', 'starts_on'],
['Éléments', 'elements'],
['Ends on (date)', 'ends_on_date'],
['2nd room', 'nd_room'],
['!!!', ''],
['', ''],
])('%j gives %j', (label, key) => {
expect(deriveFieldKey(label, [])).toBe(key)
})
it('adds _2 when the key already exists, then _3', () => {
expect(deriveFieldKey('Title', ['title'])).toBe('title_2')
expect(deriveFieldKey('Title', ['title', 'title_2'])).toBe('title_3')
})
it('keeps the first 40 characters of a long label', () => {
const label = 'abcdefghij'.repeat(6)
expect(deriveFieldKey(label, [])).toBe(label.slice(0, 40))
})
it('keeps the suffixed key within 40 characters', () => {
const long = 'a'.repeat(40)
const key = deriveFieldKey(long, [long])
expect(key).toBe(`${'a'.repeat(38)}_2`)
})
})
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
blankForm,
changeFieldType,
clearStaleDefault,
createField,
effectiveDefault,
isUnique,
keyDisplayMode,
} from '@/lib/schemaBuilderModel'
describe('changing the type of a field', () => {
const number = createField(7, {
label: 'Capacity',
key: 'capacity',
keyMode: 'manual',
type: 'number',
required: true,
unique: true,
defaultValue: '20',
min: '1',
max: '5000',
})
it('keeps the label, key and Required and resets the rest', () => {
const next = changeFieldType(number, 'boolean')
expect(next).toEqual(
createField(7, {
label: 'Capacity',
key: 'capacity',
keyMode: 'manual',
type: 'boolean',
required: true,
}),
)
})
it('starts a Choice field with no choice', () => {
expect(changeFieldType(changeFieldType(number, 'boolean'), 'select').options).toEqual([])
})
it('returns the same field for the same type', () => {
expect(changeFieldType(number, 'number')).toBe(number)
})
})
describe('blank form', () => {
it('holds the starter field Title', () => {
const form = blankForm(1)
expect([form.name, form.key, form.keyMode]).toEqual(['', '', 'auto'])
expect(form.fields).toHaveLength(1)
expect([form.fields[0]!.label, form.fields[0]!.key, form.fields[0]!.type]).toEqual([
'Title',
'title',
'text',
])
})
})
describe('stale default', () => {
it('clears a Choice default that is no longer a choice', () => {
const field = createField(1, { type: 'select', options: ['A'], defaultValue: 'B' })
expect(clearStaleDefault(field).defaultValue).toBe('')
})
it('keeps a default that is still a choice', () => {
const field = createField(1, { type: 'select', options: ['A'], defaultValue: 'A' })
expect(clearStaleDefault(field)).toBe(field)
})
})
describe('key display mode', () => {
it.each([
['manual', '', 'manual'],
['manual', 'title', 'manual'],
['auto', 'title', 'auto'],
['auto', '', 'empty'],
] as const)('%s with key %j is %s', (mode, key, expected) => {
expect(keyDisplayMode(mode, key)).toBe(expected)
})
})
describe('effective default', () => {
it('is the typed default for a plain field', () => {
expect(effectiveDefault(createField(1, { defaultValue: 'x' }))).toBe('x')
})
it('is empty for a unique field that can be unique', () => {
const field = createField(1, { defaultValue: 'x', unique: true })
expect(isUnique(field)).toBe(true)
expect(effectiveDefault(field)).toBe('')
})
it('ignores unique on a type that cannot be unique', () => {
const field = createField(1, { type: 'richtext', defaultValue: 'x', unique: true })
expect(isUnique(field)).toBe(false)
expect(effectiveDefault(field)).toBe('x')
})
it('keeps a select default only while it is one of the choices', () => {
const field = createField(1, { type: 'select', options: [' Talk '], defaultValue: 'Talk' })
expect(effectiveDefault(field)).toBe('Talk')
expect(effectiveDefault({ ...field, defaultValue: 'Other' })).toBe('')
})
})
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'
import { createField, type BuilderForm } from '@/lib/schemaBuilderModel'
import { buildPayload } from '@/lib/schemaBuilderPayload'
const EVENTS: BuilderForm = {
name: ' Events ',
key: 'events',
keyMode: 'auto',
fields: [
createField(1, { label: 'Title', key: 'title', required: true, unique: true, max: '80' }),
createField(2, {
label: 'Starts on',
key: 'starts_on',
type: 'date',
required: true,
min: '2026-01-01',
max: '2027-12-31',
}),
createField(3, { label: 'Online', key: 'online', type: 'boolean', defaultOn: true }),
createField(4, {
label: 'Capacity',
key: 'capacity',
type: 'number',
defaultValue: '20',
min: '1',
max: '5000',
}),
createField(5, {
label: 'Category',
key: 'category',
type: 'select',
defaultValue: 'Talk',
options: [' Talk', 'Workshop ', 'Meetup'],
}),
createField(6, { label: 'Description', key: 'description', type: 'richtext', max: '5000' }),
],
}
function field(key: string, rest: object) {
return { key, label: key, type: 'text', required: false, unique: false, ...rest }
}
describe('create payload', () => {
it('builds the exact body for the six-field Events form', () => {
expect(buildPayload(EVENTS)).toEqual({
key: 'events',
name: 'Events',
fields: [
{
...field('title', {}),
label: 'Title',
required: true,
unique: true,
default: null,
min: null,
max: 80,
options: [],
},
{
key: 'starts_on',
label: 'Starts on',
type: 'date',
required: true,
unique: false,
default: null,
min: '2026-01-01',
max: '2027-12-31',
options: [],
},
{
key: 'online',
label: 'Online',
type: 'boolean',
required: false,
unique: false,
default: true,
min: null,
max: null,
options: [],
},
{
key: 'capacity',
label: 'Capacity',
type: 'number',
required: false,
unique: false,
default: 20,
min: 1,
max: 5000,
options: [],
},
{
key: 'category',
label: 'Category',
type: 'select',
required: false,
unique: false,
default: 'Talk',
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
},
{
key: 'description',
label: 'Description',
type: 'richtext',
required: false,
unique: false,
default: null,
min: null,
max: 5000,
options: [],
},
],
})
})
it('has no icon key and exactly the nine field keys', () => {
const body = buildPayload(EVENTS)
expect(Object.keys(body).sort()).toEqual(['fields', 'key', 'name'])
for (const sent of body.fields) {
expect(Object.keys(sent).sort()).toEqual([
'default',
'key',
'label',
'max',
'min',
'options',
'required',
'type',
'unique',
])
}
})
it('sends null for an off switch', () => {
const body = buildPayload({
...EVENTS,
fields: [createField(1, { label: 'On', key: 'on', type: 'boolean' })],
})
expect(body.fields[0]!.default).toBeNull()
})
it('does not send the default of a unique field', () => {
const body = buildPayload({
...EVENTS,
fields: [createField(1, { label: 'T', key: 't', unique: true, defaultValue: 'Draft' })],
})
expect(body.fields[0]!.default).toBeNull()
})
it('sends no unique flag for rich text and yes or no fields', () => {
const body = buildPayload({
...EVENTS,
fields: [
createField(1, { label: 'R', key: 'r', type: 'richtext', unique: true }),
createField(2, { label: 'B', key: 'b', type: 'boolean', unique: true }),
],
})
expect(body.fields.map((sent) => sent.unique)).toEqual([false, false])
})
it('sends no limits for yes or no and choice fields', () => {
const body = buildPayload({
...EVENTS,
fields: [
createField(1, { label: 'B', key: 'b', type: 'boolean', min: '1', max: '2' }),
createField(2, { label: 'S', key: 's', type: 'select', min: '1', options: ['A'] }),
],
})
expect(body.fields.map((sent) => [sent.min, sent.max])).toEqual([
[null, null],
[null, null],
])
})
it('keeps a limit of zero', () => {
const body = buildPayload({
...EVENTS,
fields: [createField(1, { label: 'T', key: 't', min: '0' })],
})
expect(body.fields[0]!.min).toBe(0)
})
})
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
import { previewRoutes, sampleEntry, sampleJson } from '@/lib/schemaBuilderSample'
function valueOf(overrides: Partial<BuilderField>) {
const field = createField(1, { label: 'F', key: 'f', ...overrides })
return sampleEntry('events', [field]).data.f
}
describe('sample values', () => {
it.each([
['text', {}, 'Example text'],
['text', { defaultValue: 'Draft' }, 'Draft'],
['richtext', {}, 'Markdown text'],
['number', {}, 0],
['number', { min: '1' }, 1],
['number', { min: '1', defaultValue: '20' }, 20],
['boolean', {}, false],
['boolean', { defaultOn: true }, true],
['date', {}, '2026-11-21'],
['select', { options: ['Talk', 'Workshop'] }, 'Talk'],
['select', { options: ['Talk', 'Workshop'], defaultValue: 'Workshop' }, 'Workshop'],
['select', {}, null],
] as const)('%s with %j holds %j', (type, settings, expected) => {
expect(valueOf({ type, ...settings } as Partial<BuilderField>)).toBe(expected)
})
it('ignores the default of a unique field', () => {
expect(valueOf({ unique: true, defaultValue: 'Draft' })).toBe('Example text')
})
})
describe('sample entry', () => {
const fields = [
createField(1, { label: 'Title', key: 'title' }),
createField(2, { label: 'Bad', key: 'start-date', keyMode: 'manual' }),
createField(3, { label: 'Online', key: 'online', type: 'boolean' }),
]
it('wraps the data in the fixed envelope, in field order, skipping invalid keys', () => {
expect(sampleEntry('events', fields)).toEqual({
id: 42,
content_type: 'events',
data: { title: 'Example text', online: false },
created_by: 3,
updated_by: 3,
created_at: '2026-10-15T11:02:00Z',
updated_at: '2026-10-15T11:02:00Z',
})
expect(Object.keys(sampleEntry('events', fields).data)).toEqual(['title', 'online'])
})
it('indents by two spaces', () => {
expect(sampleJson('events', fields.slice(0, 1))).toBe(
JSON.stringify(sampleEntry('events', fields.slice(0, 1)), null, 2),
)
expect(sampleJson('events', fields.slice(0, 1))).toContain('\n "id": 42,')
})
it('falls back to the key events while the key is empty', () => {
expect(sampleEntry('', []).content_type).toBe('events')
expect(previewRoutes('')[0]!.path).toBe('/v1/content-type/events/entry')
})
it('lists the five routes of the type', () => {
expect(previewRoutes('recipes')).toEqual([
{ method: 'GET', path: '/v1/content-type/recipes/entry' },
{ method: 'POST', path: '/v1/content-type/recipes/entry' },
{ method: 'GET', path: '/v1/content-type/recipes/entry/{id}' },
{ method: 'PUT', path: '/v1/content-type/recipes/entry/{id}' },
{ method: 'DELETE', path: '/v1/content-type/recipes/entry/{id}' },
])
})
})
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
describe('touch keys', () => {
it('keeps type and field keys apart', () => {
expect(typeTouchKey('key')).toBe('type:key')
expect(fieldTouchKey(3, 'key')).toBe('field:3:key')
expect(fieldTouchKey(3, 'key')).not.toBe(fieldTouchKey(33, 'key'))
expect(fieldTouchKey(1, 'label')).not.toBe(fieldTouchKey(1, 'key'))
})
it('maps both choice errors to the choices touch', () => {
const choices = FIELD_ERROR_TOUCH.filter(([, touch]) => touch === 'choices').map(
([error]) => error,
)
expect(choices).toEqual(['options', 'choices'])
})
})
@@ -0,0 +1,249 @@
import { describe, expect, it } from 'vitest'
import {
createField,
starterField,
type BuilderField,
type BuilderForm,
} from '@/lib/schemaBuilderModel'
import {
countProblems,
validateField,
validateForm,
validateType,
} from '@/lib/schemaBuilderValidation'
const KEY_MESSAGE = 'Use 2 to 40 lowercase letters, digits or hyphens.'
function form(overrides: Partial<BuilderForm> = {}): BuilderForm {
return {
name: 'Events',
key: 'events',
keyMode: 'auto',
fields: [starterField(1)],
...overrides,
}
}
function field(overrides: Partial<BuilderField> = {}) {
return createField(2, { label: 'Thing', key: 'thing', ...overrides })
}
describe('type validation', () => {
it.each([
['', 'Give the content type a name.'],
[' ', 'Give the content type a name.'],
['a'.repeat(101), 'Use 100 characters or fewer.'],
['a'.repeat(100), undefined],
['Events', undefined],
])('name %j', (name, message) => {
expect(validateType(form({ name })).name).toBe(message)
})
it.each([
['e', KEY_MESSAGE],
['Events', KEY_MESSAGE],
['my_type', KEY_MESSAGE],
['-events', KEY_MESSAGE],
['events-', KEY_MESSAGE],
['events--2', KEY_MESSAGE],
['', KEY_MESSAGE],
['a'.repeat(41), KEY_MESSAGE],
['new', 'The key "new" is reserved. Choose a different key.'],
['ab', undefined],
['press-mentions', undefined],
['2026-awards', undefined],
['a'.repeat(40), undefined],
])('key %j', (key, message) => {
expect(validateType(form({ key })).key).toBe(message)
})
it.each([
[0, 'A content type needs at least one field.'],
[1, undefined],
[50, undefined],
[51, 'A content type can have up to 50 fields.'],
])('%i fields', (count, message) => {
const fields = Array.from({ length: count }, (_, index) => starterField(index))
expect(validateType(form({ fields })).fields).toBe(message)
})
})
describe('field validation', () => {
const KEY_RULE = 'Start with a letter. Use lowercase letters, digits and underscores.'
it.each([
['', 'Give the field a label.'],
[' ', 'Give the field a label.'],
['Title', undefined],
])('label %j', (label, message) => {
expect(validateField(field({ label }), []).label).toBe(message)
})
it.each([
['Title', KEY_RULE],
['1st', KEY_RULE],
['a-b', KEY_RULE],
['a b', KEY_RULE],
['', KEY_RULE],
['_a', KEY_RULE],
['a'.repeat(41), KEY_RULE],
['starts_on', undefined],
['a', undefined],
['a'.repeat(40), undefined],
])('manual key %j', (key, message) => {
expect(validateField(field({ key, keyMode: 'manual' }), []).key).toBe(message)
})
it('leaves the key error to the label while an automatic key follows a blank label', () => {
const errors = validateField(field({ label: '', key: '' }), [])
expect(errors.key).toBeUndefined()
expect(errors.label).toBe('Give the field a label.')
})
it('flags a key already used by an earlier field', () => {
expect(validateField(field({ key: 'title' }), ['title']).key).toBe(
'Another field already uses this key.',
)
})
it('flags only the second of two equal keys', () => {
const first = createField(1, { label: 'A', key: 'title', keyMode: 'manual' })
const second = createField(2, { label: 'B', key: 'title', keyMode: 'manual' })
const errors = validateForm(form({ fields: [first, second] }))
expect([...errors.fields.keys()]).toEqual([2])
expect(errors.fields.get(2)!.key).toBe('Another field already uses this key.')
})
it.each([
[
'number',
{ min: '6000', max: '5000' },
'min',
"Lowest value can't be above the highest value.",
],
['text', { min: '10', max: '3' }, 'min', "Shortest can't be longer than longest."],
[
'date',
{ min: '2027-12-31', max: '2026-01-01' },
'min',
"Earliest date can't be after the latest date.",
],
['text', { min: '2.5' }, 'min', 'Use a whole number, 0 or more.'],
['text', { min: '-1' }, 'min', 'Use a whole number, 0 or more.'],
['richtext', { max: 'abc' }, 'max', 'Use a whole number, 0 or more.'],
[
'number',
{ min: '1', max: '5000', defaultValue: '0' },
'default',
'The default value is outside the limits.',
],
[
'number',
{ max: '10', defaultValue: '11' },
'default',
'The default value is outside the limits.',
],
['number', { defaultValue: 'x' }, 'default', 'Enter a number.'],
[
'text',
{ max: '3', defaultValue: 'abcd' },
'default',
'The default value is outside the limits.',
],
[
'text',
{ min: '3', defaultValue: 'ab' },
'default',
'The default value is outside the limits.',
],
[
'date',
{ min: '2026-01-01', max: '2027-12-31', defaultValue: '2028-01-01' },
'default',
'The default value is outside the limits.',
],
['select', { options: [] }, 'options', 'Add at least one choice.'],
['number', { min: '1', max: '5000' }, 'min', undefined],
['number', { min: '5', max: '5' }, 'min', undefined],
['text', { min: '0' }, 'min', undefined],
['number', { min: '1', max: '5000', defaultValue: '20' }, 'default', undefined],
['text', { max: '3', defaultValue: 'abc' }, 'default', undefined],
] as const)('%s with %j reports %s as %j', (type, settings, where, message) => {
const errors = validateField(field({ type, ...settings } as Partial<BuilderField>), [])
expect(errors[where]).toBe(message)
})
it('skips the default check while unique values drop the default', () => {
const errors = validateField(field({ max: '3', defaultValue: 'abcd', unique: true }), [])
expect(errors.default).toBeUndefined()
})
it('flags a blank choice and a duplicate once per row, after trimming, case-sensitively', () => {
const errors = validateField(
field({
type: 'select',
options: ['Talk', 'Workshop', 'Meetup', ' ', ' Talk ', 'talk'],
}),
[],
)
expect(errors.choices).toEqual({
3: "Choices can't be blank.",
4: 'Two choices are the same.',
})
})
})
describe('problem counting', () => {
it('counts the three type-level inputs', () => {
const errors = validateForm(form({ name: '', key: 'e', fields: [] }))
expect(countProblems(errors)).toBe(3)
})
it('counts a field once whatever the number of rules it breaks', () => {
const noLabel = createField(1, { label: '', key: '' })
const inverted = createField(2, {
label: 'N',
key: 'n',
type: 'number',
min: '6',
max: '5',
})
const choices = createField(3, {
label: 'C',
key: 'c',
type: 'select',
options: ['A', '', 'A'],
})
expect(countProblems(validateForm(form({ fields: [noLabel, inverted, choices] })))).toBe(3)
})
it('counts one choice list with a blank and a duplicate as one problem', () => {
const choices = createField(1, {
label: 'C',
key: 'c',
type: 'select',
options: ['A', '', 'A'],
})
expect(countProblems(validateForm(form({ fields: [choices] })))).toBe(1)
})
it('counts one field with a bad key as one problem', () => {
const bad = createField(1, { label: 'S', key: 'start-date', keyMode: 'manual' })
expect(countProblems(validateForm(form({ fields: [bad] })))).toBe(1)
})
it('counts nothing for a valid form', () => {
expect(countProblems(validateForm(form()))).toBe(0)
})
})
+9
View File
@@ -0,0 +1,9 @@
export function moveItem<T>(items: T[], from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
return items
}
const next = [...items]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved!)
return next
}
-10
View File
@@ -14,16 +14,6 @@ export function metaSummary(authors: Byline[], siteName: string | null) {
return siteName ? t('{0} · {1}', [siteName, summary]) : summary
}
export function moveItem<T>(items: T[], from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
return items
}
const next = [...items]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved!)
return next
}
export function insertionTarget(from: number, gap: number) {
return gap > from ? gap - 1 : gap
}
+2
View File
@@ -0,0 +1,2 @@
export const HOME_PATH = '/home'
export const SCHEMA_PATH = '/schema'
+35
View File
@@ -0,0 +1,35 @@
import { BUILDER_LIMITS } from '@/lib/schemaBuilderModel'
function plain(text: string) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
}
export function deriveTypeKey(name: string) {
const slug = plain(name)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return slug.slice(0, BUILDER_LIMITS.typeKeyMax).replace(/-+$/, '')
}
function fieldKeyBase(label: string) {
return plain(label)
.replace(/[^a-z0-9]+/g, '_')
.replace(/^[^a-z]+/, '')
.slice(0, BUILDER_LIMITS.fieldKeyMax)
.replace(/_+$/, '')
}
export function deriveFieldKey(label: string, taken: readonly string[]) {
const base = fieldKeyBase(label)
if (!base || !taken.includes(base)) {
return base
}
for (let attempt = 2; ; attempt++) {
const suffix = `_${attempt}`
const candidate =
base.slice(0, BUILDER_LIMITS.fieldKeyMax - suffix.length).replace(/_+$/, '') + suffix
if (!taken.includes(candidate)) {
return candidate
}
}
}
+127
View File
@@ -0,0 +1,127 @@
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
export type FieldType = (typeof FIELD_TYPES)[number]
export type KeyMode = 'auto' | 'manual'
export type KeyDisplayMode = KeyMode | 'empty'
export type BuilderStatus = 'saving' | 'offline' | 'failed' | 'problems' | 'dirty' | 'none'
export const BUILDER_LIMITS = {
nameMax: 100,
typeKeyMin: 2,
typeKeyMax: 40,
fieldsMax: 50,
fieldKeyMax: 40,
choicesMax: 50,
}
export const RESERVED_TYPE_KEYS = ['new']
export const PREVIEW_KEY_FALLBACK = 'events'
export const STARTER_LABEL = 'Title'
export const STARTER_KEY = 'title'
export interface BuilderField {
uid: number
label: string
key: string
keyMode: KeyMode
type: FieldType
required: boolean
unique: boolean
defaultValue: string
defaultOn: boolean
min: string
max: string
options: string[]
}
export interface BuilderForm {
name: string
key: string
keyMode: KeyMode
fields: BuilderField[]
}
export function keyDisplayMode(mode: KeyMode, key: string): KeyDisplayMode {
if (mode === 'manual') {
return 'manual'
}
return key ? 'auto' : 'empty'
}
export function createField(uid: number, overrides: Partial<BuilderField> = {}): BuilderField {
return {
uid,
label: '',
key: '',
keyMode: 'auto',
type: 'text',
required: false,
unique: false,
defaultValue: '',
defaultOn: false,
min: '',
max: '',
options: [],
...overrides,
}
}
export function starterField(uid: number): BuilderField {
return createField(uid, { label: STARTER_LABEL, key: STARTER_KEY })
}
export function blankForm(uid: number): BuilderForm {
return { name: '', key: '', keyMode: 'auto', fields: [starterField(uid)] }
}
export function changeFieldType(field: BuilderField, type: FieldType): BuilderField {
if (field.type === type) {
return field
}
return createField(field.uid, {
label: field.label,
key: field.key,
keyMode: field.keyMode,
required: field.required,
type,
})
}
export function supportsUnique(type: FieldType) {
return type !== 'richtext' && type !== 'boolean'
}
export function supportsLimits(type: FieldType) {
return type === 'text' || type === 'richtext' || type === 'number' || type === 'date'
}
export function hasDefaultValue(field: BuilderField) {
return !field.unique && field.defaultValue !== ''
}
export function trimmedOptions(field: BuilderField) {
return field.options.map((option) => option.trim())
}
export function clearStaleDefault(field: BuilderField): BuilderField {
if (field.type !== 'select' || field.defaultValue === '') {
return field
}
return trimmedOptions(field).includes(field.defaultValue)
? field
: { ...field, defaultValue: '' }
}
export function isUnique(field: BuilderField) {
return field.unique && supportsUnique(field.type)
}
export function effectiveDefault(field: BuilderField) {
if (isUnique(field) || field.defaultValue === '') {
return ''
}
if (field.type === 'select') {
return trimmedOptions(field).includes(field.defaultValue) ? field.defaultValue : ''
}
return field.defaultValue
}
+66
View File
@@ -0,0 +1,66 @@
import {
effectiveDefault,
isUnique,
supportsLimits,
trimmedOptions,
type BuilderField,
type BuilderForm,
} from '@/lib/schemaBuilderModel'
export interface PayloadField {
key: string
label: string
type: string
required: boolean
unique: boolean
default: string | number | boolean | null
min: number | string | null
max: number | string | null
options: string[]
}
export interface ContentTypePayload {
key: string
name: string
fields: PayloadField[]
}
function limit(field: BuilderField, text: string) {
if (!supportsLimits(field.type) || text.trim() === '') {
return null
}
return field.type === 'date' ? text : Number(text)
}
function defaultFor(field: BuilderField) {
if (field.type === 'boolean') {
return field.defaultOn ? true : null
}
const given = effectiveDefault(field)
if (given === '') {
return null
}
return field.type === 'number' ? Number(given) : given
}
export function payloadField(field: BuilderField): PayloadField {
return {
key: field.key,
label: field.label.trim(),
type: field.type,
required: field.required,
unique: isUnique(field),
default: defaultFor(field),
min: limit(field, field.min),
max: limit(field, field.max),
options: field.type === 'select' ? trimmedOptions(field) : [],
}
}
export function buildPayload(form: BuilderForm): ContentTypePayload {
return {
key: form.key,
name: form.name.trim(),
fields: form.fields.map(payloadField),
}
}
+79
View File
@@ -0,0 +1,79 @@
import {
PREVIEW_KEY_FALLBACK,
effectiveDefault,
trimmedOptions,
type BuilderField,
} from '@/lib/schemaBuilderModel'
import { isValidFieldKey } from '@/lib/schemaBuilderValidation'
const SAMPLE_TEXT = 'Example text'
const SAMPLE_MARKDOWN = 'Markdown text'
const SAMPLE_DATE = '2026-11-21'
const SAMPLE_ID = 42
const SAMPLE_USER = 3
const SAMPLE_MOMENT = '2026-10-15T11:02:00Z'
const INDENT = 2
export function previewKey(key: string) {
return key === '' ? PREVIEW_KEY_FALLBACK : key
}
export function previewRoutes(key: string) {
const base = `/v1/content-type/${previewKey(key)}/entry`
const item = `${base}/{id}`
return [
{ method: 'GET', path: base },
{ method: 'POST', path: base },
{ method: 'GET', path: item },
{ method: 'PUT', path: item },
{ method: 'DELETE', path: item },
]
}
export function sampleValue(field: BuilderField): string | number | boolean | null {
const given = effectiveDefault(field)
switch (field.type) {
case 'richtext':
return given === '' ? SAMPLE_MARKDOWN : given
case 'number': {
const fromDefault = Number(given)
const fromMin = Number(field.min)
if (given !== '' && Number.isFinite(fromDefault)) {
return fromDefault
}
return field.min.trim() !== '' && Number.isFinite(fromMin) ? fromMin : 0
}
case 'boolean':
return field.defaultOn
case 'date':
return given === '' ? SAMPLE_DATE : given
case 'select': {
const choices = trimmedOptions(field).filter((option) => option !== '')
return given !== '' ? given : (choices[0] ?? null)
}
default:
return given === '' ? SAMPLE_TEXT : given
}
}
export function sampleEntry(key: string, fields: readonly BuilderField[]) {
const data: Record<string, string | number | boolean | null> = {}
for (const field of fields) {
if (isValidFieldKey(field.key)) {
data[field.key] = sampleValue(field)
}
}
return {
id: SAMPLE_ID,
content_type: previewKey(key),
data,
created_by: SAMPLE_USER,
updated_by: SAMPLE_USER,
created_at: SAMPLE_MOMENT,
updated_at: SAMPLE_MOMENT,
}
}
export function sampleJson(key: string, fields: readonly BuilderField[]) {
return JSON.stringify(sampleEntry(key, fields), null, INDENT)
}
+22
View File
@@ -0,0 +1,22 @@
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
export type TypeTouch = 'name' | 'key' | 'fields'
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices'
export function typeTouchKey(name: TypeTouch) {
return `type:${name}`
}
export function fieldTouchKey(uid: number, name: FieldTouch) {
return `field:${uid}:${name}`
}
export const FIELD_ERROR_TOUCH: readonly (readonly [keyof FieldErrors, FieldTouch])[] = [
['label', 'label'],
['key', 'key'],
['min', 'min'],
['max', 'max'],
['default', 'default'],
['options', 'choices'],
['choices', 'choices'],
]
+227
View File
@@ -0,0 +1,227 @@
import { t } from '@/i18n'
import {
BUILDER_LIMITS,
RESERVED_TYPE_KEYS,
trimmedOptions,
type BuilderField,
type BuilderForm,
} from '@/lib/schemaBuilderModel'
const TYPE_KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
const FIELD_KEY_PATTERN = /^[a-z][a-z0-9_]*$/
const WHOLE_PATTERN = /^\d+$/
export interface TypeErrors {
name?: string
key?: string
fields?: string
}
export interface FieldErrors {
label?: string
key?: string
min?: string
max?: string
default?: string
options?: string
choices?: Record<number, string>
}
export interface FormErrors {
type: TypeErrors
fields: Map<number, FieldErrors>
}
export function isValidTypeKey(key: string) {
return (
key.length >= BUILDER_LIMITS.typeKeyMin &&
key.length <= BUILDER_LIMITS.typeKeyMax &&
TYPE_KEY_PATTERN.test(key)
)
}
export function isValidFieldKey(key: string) {
return key.length <= BUILDER_LIMITS.fieldKeyMax && FIELD_KEY_PATTERN.test(key)
}
export function validateType(form: BuilderForm): TypeErrors {
const errors: TypeErrors = {}
if (form.name.trim() === '') {
errors.name = t('Give the content type a name.')
} else if (form.name.length > BUILDER_LIMITS.nameMax) {
errors.name = t('Use 100 characters or fewer.')
}
if (RESERVED_TYPE_KEYS.includes(form.key)) {
errors.key = t('The key "{0}" is reserved. Choose a different key.', [form.key])
} else if (!isValidTypeKey(form.key)) {
errors.key = t('Use 2 to 40 lowercase letters, digits or hyphens.')
}
if (form.fields.length === 0) {
errors.fields = t('A content type needs at least one field.')
} else if (form.fields.length > BUILDER_LIMITS.fieldsMax) {
errors.fields = t('A content type can have up to 50 fields.')
}
return errors
}
function parseWhole(text: string) {
return WHOLE_PATTERN.test(text.trim()) ? Number(text.trim()) : null
}
function parseNumber(text: string) {
const trimmed = text.trim()
if (trimmed === '') {
return null
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : Number.NaN
}
function textLimits(field: BuilderField, errors: FieldErrors) {
let min: number | null = null
let max: number | null = null
if (field.min.trim() !== '') {
min = parseWhole(field.min)
if (min === null) {
errors.min = t('Use a whole number, 0 or more.')
}
}
if (field.max.trim() !== '') {
max = parseWhole(field.max)
if (max === null) {
errors.max = t('Use a whole number, 0 or more.')
}
}
if (min !== null && max !== null && min > max) {
errors.min = t("Shortest can't be longer than longest.")
}
if (field.defaultValue !== '' && !field.unique) {
const length = Array.from(field.defaultValue).length
if ((min !== null && length < min) || (max !== null && length > max)) {
errors.default = t('The default value is outside the limits.')
}
}
}
function numberLimits(field: BuilderField, errors: FieldErrors) {
const min = parseNumber(field.min)
const max = parseNumber(field.max)
if (Number.isNaN(min)) {
errors.min = t('Enter a number.')
}
if (Number.isNaN(max)) {
errors.max = t('Enter a number.')
}
if (min !== null && max !== null && min > max) {
errors.min = t("Lowest value can't be above the highest value.")
}
if (field.defaultValue !== '' && !field.unique) {
const value = parseNumber(field.defaultValue)
if (value === null || Number.isNaN(value)) {
errors.default = t('Enter a number.')
} else if (
(min !== null && !Number.isNaN(min) && value < min) ||
(max !== null && !Number.isNaN(max) && value > max)
) {
errors.default = t('The default value is outside the limits.')
}
}
}
function dateLimits(field: BuilderField, errors: FieldErrors) {
const min = field.min
const max = field.max
if (min !== '' && max !== '' && min > max) {
errors.min = t("Earliest date can't be after the latest date.")
}
if (field.defaultValue !== '' && !field.unique) {
if ((min !== '' && field.defaultValue < min) || (max !== '' && field.defaultValue > max)) {
errors.default = t('The default value is outside the limits.')
}
}
}
function choiceErrors(field: BuilderField, errors: FieldErrors) {
const options = trimmedOptions(field)
if (options.length === 0) {
errors.options = t('Add at least one choice.')
return
}
const seen = new Set<string>()
const rows: Record<number, string> = {}
options.forEach((option, index) => {
if (option === '') {
rows[index] = t("Choices can't be blank.")
} else if (seen.has(option)) {
rows[index] = t('Two choices are the same.')
} else {
seen.add(option)
}
})
if (Object.keys(rows).length > 0) {
errors.choices = rows
}
}
function keyError(field: BuilderField, earlierKeys: readonly string[]) {
if (field.keyMode === 'auto' && field.label.trim() === '') {
return undefined
}
if (!isValidFieldKey(field.key)) {
return t('Start with a letter. Use lowercase letters, digits and underscores.')
}
if (earlierKeys.includes(field.key)) {
return t('Another field already uses this key.')
}
return undefined
}
export function validateField(field: BuilderField, earlierKeys: readonly string[]): FieldErrors {
const errors: FieldErrors = {}
if (field.label.trim() === '') {
errors.label = t('Give the field a label.')
}
const key = keyError(field, earlierKeys)
if (key) {
errors.key = key
}
if (field.type === 'text' || field.type === 'richtext') {
textLimits(field, errors)
} else if (field.type === 'number') {
numberLimits(field, errors)
} else if (field.type === 'date') {
dateLimits(field, errors)
} else if (field.type === 'select') {
choiceErrors(field, errors)
}
return errors
}
export function hasFieldErrors(errors: FieldErrors) {
return Object.keys(errors).length > 0
}
export function validateForm(form: BuilderForm): FormErrors {
const fields = new Map<number, FieldErrors>()
const earlierKeys: string[] = []
for (const field of form.fields) {
const errors = validateField(field, earlierKeys)
if (hasFieldErrors(errors)) {
fields.set(field.uid, errors)
}
earlierKeys.push(field.key)
}
return { type: validateType(form), fields }
}
export function countErrors(errors: FormErrors) {
let total = Object.keys(errors.type).length
for (const fieldErrors of errors.fields.values()) {
total += Object.keys(fieldErrors).length
}
return total
}
export function countProblems(errors: FormErrors) {
return Object.keys(errors.type).length + errors.fields.size
}
+10
View File
@@ -0,0 +1,10 @@
export const BOTTOM_SHEET_UI = {
content:
'h-11/12 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
title: 'text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0',
body: 'flex flex-col gap-4 p-4 sm:p-4',
footer: 'border-t border-default px-4 py-3 sm:px-4',
}
@@ -189,6 +189,44 @@ describe('Nuxt UI shell foundation and router guards', () => {
)
})
it('The new content type route is registered before the type key route, verified-only, titled Schema', () => {
const resolved = router.resolve('/schema/new')
expect(resolved.matched).toHaveLength(1)
expect(resolved.matched[0]!.path).toBe('/schema/new')
expect(resolved.params.key).toBeUndefined()
expect(resolved.meta.auth).toBe('verified')
expect(resolved.meta.title).toBe('Schema')
expect(resolved.meta.needsBuiltinTypes).not.toBe(true)
expect(router.resolve('/schema/articles').meta.needsBuiltinTypes).toBe(true)
expect(router.resolve('/schema').meta.needsBuiltinTypes).toBe(true)
})
it('The new content type route redirects an unverified session to the pending page', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/schema/new')
mountApp()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('The new content type route redirects a signed-out visitor to login with the original path', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/schema/new')
mountApp()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
'/login?redirect=/schema/new&reason=expired',
)
})
it('A session-only route admits an unverified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
+6 -1
View File
@@ -90,12 +90,17 @@ const routes: RouteRecordRaw[] = [
{
path: '/schema',
component: () => import('@/views/SchemaView.vue'),
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
},
{
path: '/schema/new',
component: () => import('@/views/SchemaBuilderView.vue'),
meta: { auth: 'verified', title: 'Schema' },
},
{
path: '/schema/:key',
component: () => import('@/views/SchemaTypeView.vue'),
meta: { auth: 'verified', title: 'Schema' },
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
},
{
path: '/activity',
@@ -194,4 +194,89 @@ describe('content types store', () => {
expect(store.status).toBe('idle')
expect(store.builtinStatus).toBe('idle')
})
describe('add', () => {
const recipes = { ...events, id: 2, key: 'recipes', name: 'Recipes' }
const agenda = { ...events, id: 3, key: 'agenda', name: 'agenda' }
it('inserts the type in name order, ignoring case, once the list is ready', async () => {
listContentTypes.mockResolvedValue([events, recipes])
const store = useContentTypesStore()
await store.load()
store.add(agenda)
store.add({ ...events, id: 4, key: 'press', name: 'Press mentions' })
store.add({ ...events, id: 5, key: 'zoo', name: 'Zoo' })
expect(store.items.map((item) => item.name)).toEqual([
'agenda',
'Events',
'Press mentions',
'Recipes',
'Zoo',
])
})
it('does nothing while the list is not loaded, so the next load returns it', async () => {
listContentTypes.mockResolvedValue([events, agenda])
const store = useContentTypesStore()
store.add(agenda)
expect(store.items).toEqual([])
expect(store.status).toBe('idle')
await store.load()
expect(store.items).toEqual([events, agenda])
})
it('leaves the built-in items alone and sends no request', async () => {
listContentTypes.mockResolvedValue([events])
listBuiltinTypes.mockResolvedValue([{ key: 'articles', name: 'Articles', fields: [] }])
const store = useContentTypesStore()
await store.load({ builtin: true })
listContentTypes.mockClear()
listBuiltinTypes.mockClear()
store.add(recipes)
expect(store.builtinItems).toHaveLength(1)
expect(listContentTypes).not.toHaveBeenCalled()
expect(listBuiltinTypes).not.toHaveBeenCalled()
})
})
it('keeps a type created while the custom list is still loading', async () => {
const first = deferred()
listContentTypes.mockReturnValueOnce(first.promise).mockResolvedValueOnce([events])
const store = useContentTypesStore()
const loaded = store.load()
store.add(events)
first.resolve([])
await loaded
expect(listContentTypes).toHaveBeenCalledTimes(2)
expect(store.items).toEqual([events])
expect(store.customLoading).toBe(false)
})
it('does not refetch when nothing was created during the load', async () => {
listContentTypes.mockResolvedValue([events])
const store = useContentTypesStore()
await store.load()
expect(listContentTypes).toHaveBeenCalledTimes(1)
})
it('ignores an add before any load started', async () => {
listContentTypes.mockResolvedValue([])
const store = useContentTypesStore()
store.add(events)
await store.load()
expect(listContentTypes).toHaveBeenCalledTimes(1)
expect(store.items).toEqual([])
})
})
+31 -5
View File
@@ -14,13 +14,18 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
const status = ref<ContentTypesStatus>('idle')
let pending: Promise<void> | null = null
let generation = 0
let stale = false
async function run(token: number) {
try {
const listed = await fetcher()
if (token !== generation) {
return
}
let listed: T[]
do {
stale = false
listed = await fetcher()
if (token !== generation) {
return
}
} while (stale)
items.value = listed
status.value = 'ready'
} catch (error) {
@@ -52,6 +57,7 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
function retry() {
generation++
stale = false
pending = null
status.value = 'idle'
return load()
@@ -59,14 +65,28 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
function reset() {
generation++
stale = false
pending = null
items.value = []
status.value = 'idle'
}
function insert(item: T, before: (existing: T) => boolean) {
if (status.value === 'loading') {
stale = true
return
}
if (status.value !== 'ready') {
return
}
const list = items.value as T[]
const index = list.findIndex(before)
list.splice(index === -1 ? list.length : index, 0, item)
}
const loading = computed(() => status.value === 'idle' || status.value === 'loading')
return { items, status, loading, load, retry, reset }
return { items, status, loading, load, retry, reset, insert }
}
export const useContentTypesStore = defineStore('contentTypes', () => {
@@ -93,6 +113,11 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
return Promise.all(requests).then(() => undefined)
}
function add(type: ContentType) {
const name = type.name.toLowerCase()
custom.insert(type, (existing) => existing.name.toLowerCase() > name)
}
function retryCustom() {
return custom.retry()
}
@@ -116,6 +141,7 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
error,
forbidden,
load,
add,
retryCustom,
retryBuiltin,
reset,
+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))()
+27
View File
@@ -44,7 +44,34 @@ const PAGINATION_STATES = [
'Long labels on mobile',
]
const schemaBoard = 'RenewCMS Screens Schema.dc.html'
const extraSources: Record<string, Source[]> = {
schema: [
{
board: schemaBoard,
group: '2 Schema builder',
states: [
'New type',
'Editing an existing type',
'Locked key: tooltip',
'Unsaved changes',
'Field errors',
'Type-level errors',
'Field key error',
'Choice list editor',
'Keyboard reorder',
'API preview open',
'Saving',
'Saved',
'Save failed',
'Key already taken',
'Schema incompatible',
'Offline',
],
alias: {},
},
],
sites: [
{
board: officeBoard,
+65
View File
@@ -6515,6 +6515,21 @@ function schemaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
}
}
function schemaIsMobile(page: Page) {
return (page.viewportSize()?.width ?? 0) < 768
}
async function schemaOpenRow(page: Page, label: string) {
await page.locator('[data-field-row]', { hasText: label }).first().click()
}
async function schemaCloseSheet(page: Page) {
if (schemaIsMobile(page)) {
await page.getByRole('button', { name: 'Done' }).click()
await page.getByRole('dialog').waitFor({ state: 'detached' })
}
}
const schemaStates: Record<string, VisualState> = {
Populated: { path: '/schema', api: schemaApi() },
Loading: {
@@ -6536,6 +6551,56 @@ const schemaStates: Record<string, VisualState> = {
}),
},
'Built-in type: read-only view': { path: '/schema/articles', api: schemaApi() },
'New type': { path: '/schema/new', api: schemaApi() },
'Editing an existing type': { path: '/schema/events', skip: 'pending' },
'Locked key: tooltip': { path: '/schema/events', skip: 'pending' },
'Unsaved changes': { path: '/schema/events', skip: 'pending' },
'Field errors': { path: '/schema/events', skip: 'pending' },
'Type-level errors': {
path: '/schema/new',
api: schemaApi(),
steps: async (page) => {
await page.getByPlaceholder('For example, Events').fill('x')
await page.getByRole('button', { name: 'Edit key' }).first().click()
await page.locator('input[name="key"]').fill('e')
await page.getByPlaceholder('For example, Events').fill('')
await schemaOpenRow(page, 'Title')
await page.getByRole('button', { name: 'Remove', exact: true }).click()
await page.getByText('3 problems to fix').first().waitFor()
await page.mouse.move(0, 0)
},
},
'Field key error': { path: '/schema/new', skip: 'pending' },
'Choice list editor': { path: '/schema/events', skip: 'pending' },
'Keyboard reorder': { path: '/schema/events', skip: 'pending' },
'API preview open': { path: '/schema/events', skip: 'pending' },
Saving: { path: '/schema/events', skip: 'pending' },
Saved: { path: '/schema/events', skip: 'pending' },
'Save failed': { path: '/schema/events', skip: 'pending' },
'Key already taken': {
path: '/schema/new',
api: schemaApi({
'POST /v1/content-type': { status: 409, body: 'content_type_key_taken' },
}),
steps: async (page) => {
await page.getByPlaceholder('For example, Events').fill('Events')
await schemaOpenRow(page, 'Title')
await page.getByRole('switch', { name: 'Required' }).click()
await schemaCloseSheet(page)
await page.getByRole('button', { name: 'Add field' }).click()
await page.locator('input[name="label"]').fill('Starts on')
await page.locator('label:has([role="radio"][value="date"])').click()
await schemaCloseSheet(page)
if (!schemaIsMobile(page)) {
await schemaOpenRow(page, 'Title')
}
await page.getByRole('button', { name: 'Save', exact: true }).click()
await page.getByText('is already used by another content type').waitFor()
await page.mouse.move(0, 0)
},
},
'Schema incompatible': { path: '/schema/events', skip: 'pending' },
Offline: { path: '/schema/events', skip: 'pending' },
}
export const visualStates: Record<string, Record<string, VisualState>> = {